Umbrella: parse once into Elements, and make every component consume them (#211) - #229
Conversation
Umbrella document for making every prebindgen component consume `Element`s instead of parsing captured Rust itself: the design and the rule it turns on, the measured size of the problem (202 classification sites, 113 registry map reads), the stage order L0–L5, and the completion criteria restated from #211. This file is the authority on stage state; the umbrella PR body mirrors it. Refs #211. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Stage L0 is up as #227 — |
…227) * jnigen: derive a return expansion from a value form (#213) (#221) * jnigen: derive a return expansion from a value form (#213 Gap A) `expand_return!(T).fields(fields!(t_to_struct))` takes T's output fields from its value form — the struct gathering its own accessors — instead of restating them. Two of zenoh-flat-jni's five hand-written lists had already drifted from the struct they mirror; a derived list cannot. `.fields()` is `.field()` applied to each struct field, so it keeps the same rule: a field crosses by ITS OWN type's default output boundary. A field type with an `expand_return!` splices it (a KeyExpr field still crosses as its string, not as a handle), a declared data class inlines, a field behind Option/Vec stays one leaf. Adopting it therefore preserves the boundary shape a hand-written list already had. Per-field adjustments live on the `FieldsDecl`, keyed on the Rust field ident like `FunctionDecl::expand_param`: `.field(name, expand_return!(..))` replaces one field's decomposition, `.name(name, "kt")` renames its leaf. Naming a field the struct lacks is a hard error — that is the drift this declarator exists to catch. Core changes: - `UnfoldLeaf.path` becomes `Vec<PathStep>` (`Call` / `Field`, each carrying its own optionality) so one path can mix accessor calls and field reads. Behaviour-preserving for every existing producer. - `DeconRecord::Fields` + `FieldRecord`; the adapter walks the struct (it knows which are declared classes), core decides per field whether to splice, and rides the existing visited/Cycle guard. - `UnfoldPlan.root_call` hoists the value-form call to one local, so the struct is built once per delivery rather than once per field. - `Prebindgen::deconstructors` now takes `&Registry`, matching `value_struct_decons` — a value form's fields come off the indexed struct. Sum-typed fields (ReplyStruct.result) are not covered yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: a sum-typed field of a value form (#213 Gap A, sums) `ReplyStruct { result: ReplyResult, .. }` — a `sealed_class!` field of a value form now decomposes in place into its selector and one leaf group per alternative. A sum has no whole-value converter by construction, so this is the only shape in which it can cross at all. The user-facing callback still receives ONE typed `ZOutcome`: the tag and group slots collapse into a single parameter rebuilt by an inlined `when`, reusing the `GroupDesc` collapsing that a fixed-builder arg already uses. Handing the raw slots over would have defeated the `sealed_class!`. Generalizations, both behaviour-preserving for a sum in the whole-return position (its 20 existing tests are unchanged): - the selector leaf carries the sum's own type as its `out_ty`, so the emitter finds the enum to match on from the leaf rather than from `plan.source` — which names the CONTAINING value once a sum is a field; - `encode_sum_leaves` becomes `encode_sum_group`, taking one sum's leaf segment plus the expression to match on. `encode_plan_leaves` segments the leaf list and emits one match per sum instead of the whole plan being handed to the sum emitter; a whole-return sum is the degenerate case of one segment covering everything. `Vec<sum>` and `Option<sum>` fields are refused by name: the first has variable arity, the second would need a present flag beside its tag that an output leaf list cannot carry (the `fromParts` bridge's `PlanFieldKind::Sum` can, which is why a data-class field may be `Option<sum>`). Also restores examples/example-cbindgen goldens, which the previous commit picked up from an --all-features regeneration. The generator output is unchanged; only the committed artifact was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * examples: restore example-cbindgen goldens to the plain-build variant An earlier `git add -A` in this branch swept in an --all-features regeneration, whose FEATURES guard reads "example-flat/internal example-flat/unstable" instead of "". `examples/regen-check.sh` builds with default features, so the committed artifact has to be the default-feature one — this is what CI checks. The generator output is unchanged either way; only the committed file was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * covertest: exercise the derived value-form boundary on the JVM (#213) Library tests alone do not count as coverage in this repo, so `.fields()` gets a real round trip: `perftest_flat::ext::Report` is a handle whose output boundary is declared from its value form, with each field landing on a different rule of the expansion — summary a type with its own expand_return! ⇒ spliced into (count, total), NOT handed over as a handle taken Option<data class> ⇒ one leaf origin a non-optional data class ⇒ inlined into its fields outcome a sealed_class! ⇒ selector + one group per alternative, carrying a handle label a plain leaf `Test.kt`'s new section is itself the assertion: the callback signature would not compile if any field had been derived wrongly. It also pins the ownership contract for a handle reached through a value form and a sum group — live inside the callback, still live after, the receiver's to close. 47 sections pass on a real JVM. Adds the Gap B unit test the issue asked for: a handle-payload sum in DATA-CLASS FIELD position, the one position return/callback coverage did not reach. It works — and the test pins two consequences that were previously unstated: the container is NOT AutoCloseable (a sum payload is the receiver's to close, unlike a plain handle field, which cascades), and a sum field pushes its parent onto the whole-value fromParts bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: address review on #221 — three value-form defects P1 — a single-leaf value form passed a borrow to an owned converter. One leaf makes core pick `Delivery::Return`, whose reach is composed separately in `emit/wrapper.rs`'s `is_convert` path. That path rendered a `Field` step as `&(expr).field` and returned it, so a plain field leaf — whose `out_ty` is the field type as written — got `&F` where its converter takes `F`, and a non-`Copy` field additionally borrowed out of the temporary the value-form call returned. It now clones the reached place, the same treatment `encode_plan_leaves` gives a `LeafSource::Field` leaf; an identity leaf stays borrowed, since its converter IS the borrowed-opaque clone. P2 — a per-field override did not validate its declared type. `.field("key_expr", expand_return!(ZBytes)...)` was accepted for a `ZKeyExpr` field whenever both were declared handles, and an override silently outlived an upstream field-type change — the exact drift `.fields()` exists to catch. The declared key is now compared against the peeled field type and names both, matching the target checks on the per-function expansion APIs. P2 — nested value forms were not hoisted. `root_call` only searched the declaration's top-level records, so a field splicing a child whose own boundary is also derived rebuilt that child once per child leaf, breaking the stated "called once per delivery" contract. Replaced by `UnfoldPlan.hoists: Vec<Vec<PathStep>>` — the path prefixes to bind once, recorded where `flatten` descends and therefore outermost-first. Each is composed from the longest already-bound prefix of itself, and each leaf reaches off the innermost hoist it sits under: let __vf0 = z_outer_to_struct(&arg); let __vf1 = z_inner_to_struct(&(&__vf0).inner); This also removes the single-value-form special case rather than adding a second one beside it. Three regression tests, one per finding. The only generated-output change is the `__vf` -> `__vf0` rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: validate nested value-form field shapes * jnigen: consuming value forms — move the fields instead of cloning them `.fields(fields!(f))` now accepts a value form that takes its receiver BY VALUE. Such a form destroys the object into its parts, so the generated code moves the value in and moves each field OUT into its leaf — the clones the borrowing form pays disappear entirely. This is what the hot receive path wants and what zenoh itself recommends: `From<Sample> for SampleFields` exists, in zenoh's words, because it "allows deconstructing a sample to fields without cloning, which is more efficient than using getter methods". Every callback hands its value over owned (`impl Fn(Sample)`), so there is nothing to preserve — the borrowing form clones fields out of a value it is about to drop. Measured on covertest's `Report`: six clones removed from the callback body, `report_into_struct(__cb_arg0)` moved in, every field moved out. Consuming-ness is INFERRED from the accessor's signature, so it cannot drift from it, and both forms stay usable side by side. Because a consuming form moves the value, two shapes are refused at declaration time rather than emitted as Rust that cannot compile downstream: a sibling record (`.field_self()` or another `.field()` would read a moved value), and a form reached through another value form (it would move a field out from under the parent's other leaves). A `&T`-returning function clones once up front and consumes the clone, so one declaration still serves owned and borrowed returns alike. Two supporting changes: - The reach derivation is now SHARED (`reach_leaf_flat`) between the multi-leaf encoder and the single-leaf `Delivery::Return` shortcut in emit/wrapper.rs. Deriving it twice is what let them drift into the P1 defect; the shortcut also now refuses an optional intermediate step explicitly instead of composing code that cannot type-check. - Reaches project the leading run of plain field steps DIRECTLY (`&v.a.b`) instead of through a borrow of the base (`&(&v).a.b`). The two name the same value, but the second borrows the base as a whole, which the borrow checker rejects once a sibling leaf has moved another field out — so without this, field moves compiled only while the borrowing leaves happened to be declared first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: `.fields_into()` — declare the consuming value form, and let it nest `6133f91` taught `.fields(fields!(f))` to accept a by-value accessor and INFERRED consuming-ness from its signature. That reads the decision off the wrong thing. Giving the value away is a boundary decision — the same one `.field_self()` makes, which is exactly why the two cannot coexist — not a property of which function happened to be named. So the collision surfaced as a resolve-time error phrased as a restriction on `.fields()`, when it is really two declarators to pick between. Now the decl says which it wants: .field_self() the value itself, whole .fields(fields!(to_struct)) a copy of its parts .fields_into(fields!(into_)) the value itself, as its parts `.fields_into(..)` must be the decl's only record — a `.field_self()` or a sibling `.field(..)` would read a value that is gone — and that is now a panic in the declarator, in BOTH orders, rather than an `UnfoldError` found a resolve later. The declared flag and the accessor's receiver are cross-checked when the records are flattened, so intent still cannot drift from the signature; naming the wrong one of a `to_struct`/`into_struct` pair is an error that says which declarator the accessor belongs to. The nesting refusal is GONE. Its stated reason — "it would move a field out from under the parent's other leaves" — does not hold: a hoisted value form is an owned struct, its fields are disjoint, and `project_leading_fields` (same commit) already stopped leaves from borrowing the base as a whole. So a nested consuming form is handed the parent's field BY MOVE: let __vf0 = z_outer_to_struct(&__cb_arg0); let __vf1 = z_inner_into_struct(__vf0.inner); // moved, not cloned … __vf0.tag … // sibling leaf, still fine `compose_step` borrows (`&(e).f`), so the field run to that field is projected in the hoist loop instead of going through it. A nested form reached through an accessor CALL holds a borrow with nothing to give up, so it clones once and consumes the clone — the same fallback a borrowed root already takes. That was the one place an available `_into_struct` went unused for no reason. Verified: 438 lib tests (three retargeted, five new — both collision orders, both signature-mismatch directions, and the nested move under a borrowing AND a consuming parent), covertest-kotlin's 47 JVM sections, regen-check byte-clean. The generated output for covertest is unchanged — same accessor, same moves; only the declaration that names it moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: address review on #221 — consuming ownership in two more places Two review findings, both cases where `.fields_into(..)` promised a move and the emitter did not deliver one. [P1] The single-leaf `Delivery::Return` shortcut never consulted the plan's hoists. It composed its reach straight off the raw value, so a one-field value form declared with `.fields_into(..)` emitted (&(myflat::z_one_into_struct(&__cvsrc)).label).clone() — `&ZOne` handed to a by-value receiver, ill-typed in the consumer's crate before you even reach the pointless clone. Every consuming test so far produced a MULTI-leaf callback plan and went through `encode_plan_leaves`, so nothing covered it. The hoist loop is now `bind_hoists`, shared by both paths, and `reach_leaf_flat` takes the rebased path plus its hoist's `consuming` flag. The shortcut binds the same `__vfN` locals as the multi-leaf encoder and reaches the leaf off the innermost one. That is the same fix that was applied to the reach itself in `6133f91` and for the same reason: two derivations of one question drift. [P2] The identity branch computed `consuming` and then returned before using it. Only a handle at the owned ROOT (empty path) moved; a handle FIELD always took the clone-via-converter arm: ZChild_to_jlong_...(&mut env, &__vf0.child) despite the parent form having given its value away — a preserved clone, and a `Clone` bound the handle type need not have. The branch now computes the owned PLACE (the root, or a plain-field run under a consuming hoist) and boxes it, `Box::into_raw(Box::new(__vf0.child))`. Both regressions reproduce the reviewer's exact shapes and both fail without the corresponding fix (verified by stashing each). Sum payloads, which the P2 comment also flagged, are NOT fixed here: filed as #228. `encode_sum_group` matches by reference and clones every payload kind through one chain, so moving means reworking that emitter's ownership model — the selector reads the same matched value, and an owned handle payload wants the identity branch's box rather than the borrowed-opaque converter. Not an addendum to this PR. Verified: 440 lib tests, covertest-kotlin's 47 JVM sections, regen-check byte-clean (neither shape occurs in covertest, which is why its goldens do not move — the unit regressions are what pin them). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: decide leaf ownership in the plan, not in each emitter Two more review findings on #221, both the same defect wearing a different hat: an identity (handle) leaf under a consuming value form was still reached as a borrow, so the borrowed-opaque converter cloned it — and demanded a `Clone` the handle type need not have. * A value form whose SOLE field is a handle takes the single-leaf `Delivery::Return` shortcut. `bind_hoists` called the by-value accessor correctly, then the shortcut returned `&__vf0.child`, because its consuming case only covered `LeafSource::Field`. * An `Option<Handle>` field was excluded by the previous fix's plain-field test, leaving `match &(&__vf0).child { Some(__n0) => …clone… }` — an ordinary optional handle field, not the sum limitation of #228, and the commonest shape there is (`SampleStruct.attachment`). Patching each emitter would have been a third special case for one question. The question belongs to the PLAN: `place_is_owned` now decides, where an identity leaf's `out_ty` is chosen, whether the value at that path is the plan's to give away — the root of an owned plan, or a field of a form that CONSUMED its value, reached by a movable run of steps. An owned `out_ty` IS that statement, and it already selects the owning converter, so every emitter follows one decision instead of re-deriving it. `steps_are_movable` (plan.rs) is that run: field reads only, with an `Option` allowed on the LAST one — a `None` arm still hands the whole `Option` over by value, while an `Option` in the middle must be unwrapped and so can only be borrowed through. The resolver and both emitters read the same predicate; two readings would drift, and the disagreement is a borrow handed to an owning converter. Emitters then just project the place: * `reach_leaf_flat` moves whenever the leaf owns its `out_ty` — field and identity leaves alike. It keeps requiring a plain-field run, since return delivery has no `None` arm for a trailing `Option`. * The nullable identity branch matches the `Option` BY VALUE and boxes the `Some` payload, instead of matching a borrow of it. Both regressions reproduce the reviewer's shapes and fail without the fix (verified by stashing it). 442 lib tests, covertest's 47 JVM sections, regen-check byte-clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: a nullable sole leaf is a callback delivery, not a return `single_return` chose `Delivery::Return` on leaf COUNT alone. A value form whose only field is an `Option<Handle>` therefore landed on the flat return path, which has no `None` arm and whose `convert_out_ty` names the leaf's own type rather than an optional of it — so it composed &(&__vf0).child into `ZChild_to_jlong(.., __out)`, typed for `ZChild`. The downstream crate does not compile. Making `out_ty` owned in 421531e addressed move-vs-clone; it says who frees the handle, not whether there is one. Absence is a DELIVERY question. Callback delivery already has the arm — the leaf crosses as a boxed `Long` or JVM null — so a nullable leaf goes there, which is one condition on `single_return` rather than teaching the shortcut to match and map a trailing option it has no way to represent in its return type. Nullability here only ever comes from an `Option` with something DECOMPOSED below it (a `.field_self()` handle, a nested value form); a plain leaf's own `Option` rides its converter and leaves the leaf non-nullable. So no shape that returns today stops returning — regen-check is byte-identical and covertest's 47 sections are unchanged. Regression reproduces the reviewer's shape and fails without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: an owned root identity moves on the flat return path too The flat return path asked the wrong question. It tied the move to the rebased hoist's `consuming` flag, but "a consuming form gave it to me" is only ONE of the two ways a leaf owns what it reaches. A plain `-> ZChild` return under the type-level `expand_return!(ZChild).field_self()` — the declaration that exists so the same boundary can be spliced as a value-form field — has no hoist at all, so `consuming` was false and the path emitted let __cvsrc = myflat::z_root_child_make(); { &__cvsrc } into the OWNING `ZChild_to_jlong`, whose argument is `ZChild`. Same mismatch inside the `map` closure of an `Option<ZChild>` return. For an identity leaf the plan already states ownership — that is what `place_is_owned` decides and what selected the owning converter — so the emitter reads it off `out_ty` instead of re-deriving it. A field leaf keeps asking the enclosing form, since its `out_ty` is the field type as written and owned either way. That predates this PR: the previous shape of this path composed `&base` for an empty path regardless. The callback emitter has always treated the owned root as an owned place; now both do. Regression covers the plain and the `Option` return and fails without the fix. 444 lib tests, covertest's 47 JVM sections, regen-check byte-clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * jnigen: rename `.fields_into()` to `.fields_self_into()` Puts the declarator squarely in the `field_self` family it belongs to, which is the whole point of it being its own declarator: `.field_self()` hands the value over whole, `.fields_self_into(..)` hands *the value itself* over as its parts, and `.fields(..)` hands over a copy of its parts. `self` is what the first two share and what makes them mutually exclusive. Mechanical: the method, the two panic messages, the doc links, the covertest declaration and its coverage-table row. Generated output is unchanged — regen-check byte-clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * Parse the record stream into elements that keep their syntax `Language` turns a captured `(syn::Item, SourceLocation)` stream into `Element`s: a closed, destination-neutral classification paired, at every level, with the exact syntax it was built from — the item, each parameter, field, variant and type. The pairing is the point. Issue #211 asks that adapters stop re-reading captured Rust, and the natural reading of that — a syn-free semantic model — makes the model responsible for reconstructing Rust too, because the generated glue is itself a destination artifact. That pressure is what turns a language-neutral IR back into a second `syn`: a delimiter, a lifetime and a literal's base all have to be modelled so they can be re-emitted. Keeping the original slice costs nothing and removes the pressure, so the classification stays small: Element::Enum → Variant { tag, discriminant: Option<i64>, fields, syntax } `B()` is a unit *group* and still spells `E::B()`, because `Variant::spell` reads the delimiters off `syntax`. `= 0x07` reaches a C header as `0x07` while Kotlin gets the number 7. Neither is a modelled fact. The rule for consumers is therefore: **classify off `kind`, spell off `syntax`.** #224's boundary ledger measures exactly that without adaptation — it counts variant mentions of `syn::Type` / `syn::Expr`, so `quote!(#slice)` is invisible to it and `matches!(ty, syn::Type::Reference(_))` is not. It is ported here and seeded at 202 sites, the population the adapter migrations pay down. Acceptance is preserved, not expanded. An item the language cannot express becomes `Element::Unsupported`, carrying its diagnosis: the pipeline has always scanned a signature only once an adapter declares it, and a source crate may mark items no binding uses. Only a duplicate name — which no declaration can disambiguate — fails the parse. Nothing consumes elements yet; `Registry::from_elements` is the next step. Ported from the #215 branch: the array-length subgrammar (#212), the type grammar and its acceptance tests, enum tag/discriminant numbering (#226), the ledger (#224). Refs #211. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Make the element model logical, not Rust-shaped `TypeKind` still named Rust type constructors where it should have named concepts, and the identity of a nominal type was a `syn::Path` sitting inside the classification — a position the boundary ledger cannot see. The test a variant has to pass is whether a *destination* language would act on the distinction; if only Rust can tell, it is spelling, and the syntax slice already carries it. Twelve variants become ten: * `Slice` folds into `Sequence`. `Vec<T>` and `[T]` are one concept — a run of `T` — and ownership is already the `Ref` layer's fact, so a second variant encoded it twice. This is what the pipeline does anyway: one `Shape::Iterable` covers both, and jnigen rewrites a `&[T]` input into the `Vec<_>` pattern. * `Boxed` goes. `Box<T>` **is** `T`: owned either way, and nothing outside Rust can tell. It classifies as what it wraps, and the `Box` survives where it matters — in the syntax generated Rust spells. * `Ptr` goes. No source crate writes a raw pointer, neither adapter has a selection arm for one, and accepting it *widened* acceptance, which this stage was not supposed to do. * `Str` covers `str`, so `&str` is a borrowed string rather than a reference to a nominal type nothing can resolve. It is the most common non-scalar parameter in the whole ecosystem, and both adapters already special-case it by name. * `Named` carries a `TypeId` — a name — instead of a `syn::Path`. The same test applied to the elements: a function's return is a `Type`, unit when elided, because no consumer distinguishes that from `-> ()` (eight of them normalize one to the other on the spot). A struct's fields are `Option<Vec<Field>>` — a product, or opaque — because named/unnamed/unit were three Rust shapes where `Variant` already modelled the same idea as a field list plus delimiters read off the syntax. `spell.rs` now holds everything that turns an element back into Rust tokens, so `element.rs` describes structure alone, and `Struct::spell` joins `Variant::spell` as the dual that makes the shapes unnecessary. Two things move to where they belong: `Language::parse` normalizes before lowering (`ty.rs` already assumed it had), and the callback grammar `extract_fn_trait_args` lives in the language rather than the registry — one ledger site paid down, 202 to 201. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Delete the passthrough element A `#[prebindgen]` crate marks the items that cross the boundary; the supporting code around them belongs to the consumer. The proc-macro already enforces that — marking a `use`, `mod`, `impl` or `macro_rules!` is a compile error at the mark site — so the variant's own doc listed items that could never reach it. What actually reached it was one thing: the `const _` feature guard, which is not a source item at all. `CfgFilter` synthesizes it and prepends it to the stream, so `Passthrough` existed to carry an item prebindgen itself wrote. It is a const, so it is modelled as one, and `Element::name` returns `None` for `_` — which is the real fact, and the one that lets several sources' guards coexist in the flat namespace. `write.rs` already had that rule for consts (`*ident == "_"` bypasses the declaration gate), dead until now because `const _` never reached the consts map. That leaves `union` and a type alias, the two kinds the macro accepts and the frontend does not model. Neither is written by any source crate in the ecosystem. They become `Unsupported` with a diagnosis naming the kind, rather than being copied verbatim into generated code that would reference source types by bare name — so the mark site and the frontend now disagree about exactly two kinds, and disagree loudly instead of silently. `Unsupported::name` becomes optional, since an item kind may have no identifier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Give every node one Origin: its syntax, and where that syntax came from The classification is now logical, but its other half was still ad-hoc. `syntax` sat on nine node types as nine separate fields; `location` sat on the five item types only, because a captured record is per-item and a component has none of its own. That asymmetry had a cost. The one semantically load-bearing part of a location — the crate name — was reachable at item level only, so it got copied downward by hand, under a third field name, with drifting meaning: `ConstId.origin` is the crate a const was *declared* in, while `TypeId.origin` was the crate of the item *using* the type. The latter was also part of `TypeId`'s derived `Eq`, so `Sample` referenced from two source crates compared unequal — one type with two identities, three lines under a doc calling the name "the whole address". The two facts are orthogonal and neither derives from the other. `syn` tokens normally carry spans, but the proc-macro serializes each item as a string into JSONL and `build.rs` re-parses it, so every span in a slice points into an anonymous buffer; `SourceLocation::from_span` captures file/line/column while real rustc spans still exist, precisely because they cannot survive the trip. So every node now carries `Origin<S> { syntax: S, location: Rc<SourceLocation> }` — item, parameter, field, variant, type, and the array extent, which had no syntax at all and now spells its own length. Generic, so the typed slices survive; `Rc` because the model holds `syn` and is `!Send` regardless, the call `TypeKey` already made. One captured record is one item, so an item and every node lowered out of it share one allocation, which is both the honest answer to "where is this field" and the cheap one. With provenance arriving on its own, `item_crate: Option<&str>` stops being threaded through six lowering functions, `TypeId` is a name alone, and `ConstId.origin` becomes `ConstId.crate_name` — a crate that belongs to a *different* item, not this node's provenance. The rule, now stated where it can be read: a reference carries a name, the declaration carries the origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * A variant's position is an index, not a tag `Variant.tag: i32` and `Field.index: usize` were one fact under two names: the ordinal of a child within its parent's ordered list. Sum versus product is already carried by *which* list it is — `Enum::variants` or `Struct::fields` — not by the number. The defence for keeping them apart was that a tag is transmitted while an index is only used to address a field. That defence was made of adapter behaviour: `i32` because cbindgen writes `c_int` and jnigen writes `jint`. Deciding a frontend field's shape from two generators' wire types is exactly the coupling this module exists to prevent, and it is the same test that stripped `Boxed` and `Slice` — a fact earns its shape from what the source means, not from what one adapter does with it. Transmitting the position to say which alternative is live is one destination's choice; another may send a name. The signedness had no defence at all: a declaration-order position is `0..N-1`. So `Variant.index: usize`, matching `Field.index`, and both documented as the same fact for the same reason — a node handed out on its own still knows where it sits. What remains genuinely distinct is `Variant::discriminant`: a position is where the source *put* a variant, a discriminant is the value Rust *assigns* it, and the two are independent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: extent identity, callback returns, i64::MIN Three correctness fixes before this becomes the model later stages consume. **`ArrayExtent` had an equality that was neither identity it could have been.** It compared `value` and `source`, so `[u8; A]` differed from `[u8; 4]` when `A == 4` — one Rust type reported as two — while `[u8; 4]` equalled `[u8; 0x04]`, whose retained syntax differs. So it was not type identity and not spelling identity, and its own doc claimed the first while the code did neither. There is no single equality that could be right, because the extent answers three different questions, so it now provides none and each consumer projects what it needs: `value` for type and converter identity, `origin.syntax` for a C declaration's spelling at that occurrence, `const_id()` for which consts must reach the header. A regression pins all three apart — same value with different const dependency, same value with different spelling, same value with different const. The doc also records what a converter table will need: `value` being the identity means occurrences share one converter with differing spellings, so a canonical spelling must be chosen deliberately rather than inherited from whichever occurrence populated the entry. **The callback grammar silently dropped a return type.** `extract_fn_trait_args` read `ParenthesizedGenericArguments::inputs` and never `output`, so `impl Fn() -> u8 + Send + Sync + 'static` was accepted as `Callback { args: [] }`. `TypeKind::Callback` has no slot for a return and the grammar's own error text says a callback returns `()`, so the fact was lost — silently, which is worse than refusing. A non-unit return is now refused, a written `-> ()` still accepted, both with tests. No source crate in the ecosystem writes a returning callback, so nothing real narrows. The helper predates this PR, but making it the authoritative frontend classifier is what would have made the loss irreversible for every later consumer. **`i64::MIN` was not a discriminant.** `int_literal` parsed the magnitude as `i64` before applying the sign, so `-9223372036854775808` — valid Rust — failed at the digits. The magnitude is now parsed as `i128` and range-checked after negation, with a regression at the bottom of the range and one step past it. Along the way, `is_unit_type` becomes the language's one answer to "is this `()`", used by both the type lowering and the callback check. `types_util::is_unit` could not serve: it is gated behind `unstable-cbindgen`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: async, variadic, generic binders, and a ledger hole Three more shapes the frontend accepted but could not represent, and one hole in the check that is supposed to catch exactly this class of thing. **`async fn` was the dangerous one.** `Function` has a direct return, so `pub async fn ping() {}` lowered as a function returning `()` — a generated wrapper would call it, drop the future, and export a function whose body never runs. A **C-variadic** tail was dropped from the signature just as quietly. Both are now `ItemError`s. **A type or const generic parameter is refused.** The elements have no generic binder, so a `T` in a field or parameter lowered as `TypeKind::Named` — an ordinary reference into the flat namespace, indistinguishable from a real item called `T`, which loses the scoping every downstream resolver needs. Modelling binders and substitution is the other option; refusing is the right one, because no destination language can express an uninstantiated parameter, and the source crates already write concrete types per instantiation. The diagnosis says so. Two things are deliberately *not* generic binders, both tested. A lifetime parameter: lifetimes are spelling and the spelling already travels, the same call `lower_type` makes for a lifetime argument. And `impl Trait` in argument position — Rust calls it an anonymous type parameter, but `syn` does not desugar it into the binder list, so the callback form every callback-taking source function uses is untouched. **The boundary ledger could be evaded.** `is_cfg_test` treated any predicate containing the ident `test` as test-only, so a classifier under `#[cfg(not(test))]` or `#[cfg(any(test, feature = "x"))]` was skipped — in a production build. It now matches the exact predicate `cfg(test)` and counts everything it cannot prove test-only, which is the safe direction for a check whose job is to stop a classifier hiding. `cfg(all(test, ..))` is genuinely test-only and is counted anyway; nothing in the tree writes one, and widening it later should be a deliberate edit with a ledger diff attached. The count does not move: every `cfg` on an item in the tree is either exactly `cfg(test)` or mentions no `test` at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Let Language read a source directory, not just a stream A build script's whole prebindgen preamble was two steps and a binding it did not otherwise want: let source = prebindgen::Source::new(zenoh_flat::PREBINDGEN_OUT_DIR); let registry = Registry::from_items(source.items_all())?; `Language` now folds the first step in, so naming the directory is enough: let elements = Language::new() .source(zenoh_flat::PREBINDGEN_OUT_DIR) .parse()?; That is five of the six consumer build scripts — zenoh-flat-jni, zenoh-flat-c, perftest-c, perftest-kotlin, example-cbindgen — which use nothing of `Source` but `new` and `items_all`. Reading a stream is kept, as the general case rather than the only one: `items()` takes any `(syn::Item, SourceLocation)` iterator, so everything a `Source` can express still composes — a group selection, a renamed dependency (covertest-kotlin's `crate_name` override, the sixth build script), several sources at once. `source()` is sugar over it. The other four knobs on `Source`'s builder — group selection and feature/target filtering — are reachable this way and were not mirrored, because no build script in the workspace calls them. The feeders accumulate and `parse` consumes, rather than each input being parsed as it arrives. That is forced, not stylistic: the rules that make a parse fail are whole-stream — one flat namespace, one const index an array length may reach into, one set of source modules to normalize against — so every input must be in hand before any of it is classified. A test now pins both directions of that: a length in one feeder resolving a const from another, and a duplicate name across feeders still failing. `Language` and `Element` join `Registry` in the `core` facade, since they are what a build script names; the rest of the element model stays in `core::language`, where an adapter reaches for it. The four doc examples on `Language` are now real doctests rather than `ignore` blocks — `Source::init_doctest_simulate` was already there to make that possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Split the two enum shapes: a Variant is not an Enum `Element::Enum` covered both a payload-carrying enum and a fieldless one, on the theory that the second is the degenerate first. They are two entities, and the evidence is in how they are numbered. A sum's alternatives are identified by **position**: cbindgen states it outright — "the mirror carries no explicit discriminants, so its tags are declaration order `0..N`" — and jnigen's sum emission mentions `discriminant` exactly zero times against eleven uses of the position. A fieldless enum's members are identified by the **value Rust assigns**: a C header re-states each `= expr`, and a Kotlin `enum class` entry is `NAME(7)`, with position only a fallback when the discriminant is not a literal. So one model covering both carried a field dead in each direction — and worse than dead on the sum side, because Rust *does* assign a discriminant to a payload alternative and using it would be wrong. The unified model invited exactly that mistake. Element::Variant(Variant { alternatives: Vec<Alternative> }) // a sum Element::Enum(Enum { values: Vec<EnumValue> }) // C-style `Alternative` carries `index` and `fields` and no discriminant; `EnumValue` carries `index` and `discriminant` and no fields. `discriminant_values` belongs to `Enum` alone now. `is_unit` and `first_payload_variant` are gone: the first was the classification, which `lower_enum` now makes once, and the second existed to name an offender to an adapter that only accepts fieldless enums — such an adapter matches `Element::Enum` and never sees the other shape. Both shapes still spell delimiters off their own syntax, because `A`, `B()` and `C {}` are fieldless alike and Rust demands the delimiters wherever the last two are named — so `spell` is on `Alternative` and `EnumValue`, over the one `spell::fields`. `enum E {}` and an all-empty-group enum are `Enum`; one field anywhere makes the item a `Variant`, and a sum may still mix empty and payload-carrying alternatives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…232) * Rename the module to flat: these are the flat API's elements `core::language` modelled one thing and was named for another. What it parses is the **flat API** — the single flat namespace a `#[prebindgen]` crate exports — so `Language` becomes `Flat` and `api/core/language/` becomes `api/core/flat/`. Mechanical, and separated from the model changes that follow so those arrive as a readable diff. The boundary ledger's skipped-path constant and header move with the directory; the count does not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Element is a function, a type, or a constant `Element` mixed two levels: `Function | Struct | Variant | Enum | Const` set type declarations beside functions and constants, when the kinds a binding distinguishes are a function, a type, and a constant. Types now group under `Element::Type`, and the type *reference* — which held the name `Type` — becomes `TypeRef`, so a declaration and a use site stop sharing a word. `Opaque` becomes the entity for a type whose contents do not cross, and it arrives two ways: * `#[prebindgen] pub type X = path;` — this **reverses** #227, where a marked alias was `Unsupported`. It is now how a handle enters the flat API deliberately: a foreign or crate-private type gets a name here without any claim about its contents. That is what makes the API closable, and it is the prerequisite for requiring references to resolve. * a marked tuple struct, whose fields no adapter has ever crossed — unchanged acceptance, now named for what it always meant. So `Struct::fields` drops its `Option`. `None` was the opaque case; an empty list now means the source wrote a struct with no fields, which is a different thing. `MaybeUninit<T>` joins the grammar as `TypeKind::Uninit`. It is a boundary concept — an out-parameter whose slot the caller supplies and the callee fills — and cbindgen already models it as exactly that, so this moves a classification out of the adapter and into the frontend, per #211. It is also the one foreign generic that no alias could name, a generic alias being a generic binder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Flat resolves its references and answers by name Two changes that belong together, because the first is what makes the second decidable. **The model is addressed by name, not iterated.** `FlatBuilder` collects and `build` hands over a `Flat` — `function(name)`, `declared_type(name)`, `constant(name)`, `element(name)`, plus iterators over each kind. Names are unique across the whole model, so a name is a complete address, and that is what every later stage wants: an adapter asks what a declared name *is* rather than scanning a list. L1 carried this as a checklist bullet; it is really a property of the model. Two types rather than one, because a half-built model should not be the same type as a resolved one — `Source::builder()` sets the precedent. **References resolve at parse time.** A third pass walks every `TypeRef` — through `Option`, `Vec`, `&`, `Result`, arrays, callback arguments and generic arguments alike — and an item naming a type the flat API does not declare becomes `Element::Unsupported` with `ItemError::UnresolvedType`. Deferred, not fatal, like every other refusal: an item no binding declares stays harmless. This is what a marked type alias bought. A dangling name previously surfaced far downstream as an unresolved *converter*, from whichever adapter happened to look first — the "one fact, several authorities" #211 exists to end. Note the two remain distinct: resolution here says a name denotes something, while an adapter's resolver still decides whether it supplied a converter for it. A path-qualified name gets its own diagnosis, since `#[prebindgen] pub type foreign::Option = ..` is not a spelling that exists — marked items live in one flat namespace of bare names. Also: `Item::Type` no longer reaches the registry's passthrough. An opaque declaration states something about the API's surface and is not code to copy into the binding; its target is routinely crate-private, so re-emitting it would not compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the example flat APIs, and assert they stay closed Every type a marked signature named had to become a declaration for resolution to mean anything. Two idioms, chosen by what the type actually is rather than by its Rust shape: **A handle gets a marked alias.** `Storage`, the three callback handlers, `Token`, `TokenGc`, `Summary`, `Archive`, `Report`, `EscapeProbe`, `StorageError`, and example-flat's `Calculator` move into a private `handles` module, with `#[prebindgen] pub type X = handles::X;` at the top level. The alias is transparent, so every signature still says `Storage`. `Error` in both crates was already an alias and only needed the attribute — which is exactly the shape zenoh-flat's 26 zenoh re-exports will take. **A public newtype stays a marked struct.** `Millis`, `Celsius`, `Percent` and `Label` are not handles: they cross by `convert!`, and covertest-helpers both constructs them and reads `.0`. Hiding them behind an alias broke that downstream, which is the useful signal — a type alias names the type, not the tuple-struct constructor, and the constructor lives in the value namespace where the struct is defined. In-crate construction of the relocated handlers is qualified `handles::PayloadHandler(..)` for the same reason. Marking these as structs rather than aliases matters for a second reason: a marked struct enters `registry.structs`, and `write.rs` emits `on_struct` for any declared type there — so marking the *handles* as structs would have changed generated output. The alias route is invisible to the registry, which is why the goldens hold. **And the closure is asserted, not assumed.** covertest-kotlin's build script now runs `Flat` over both sources and fails if anything is unsupported. It is the right place: only there do the helper crate's references to perftest-flat's types resolve, since it cannot mark them itself. Verified by deliberately unmarking `Storage` — the build fails naming all twelve referencing functions and the fix. Generation is byte-identical (`examples/regen-check.sh`) and the JVM covertest passes all 47 sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Record L0.5 in the stage map The model is now indexed and resolved, which takes two bullets off L1 — elements indexed by name, and the entry point that shares one parser — and adds a prerequisite L0 did not have: the flat API has to be closed for resolution to mean anything. Also records what is left open: zenoh-flat and its two consumers are separate repos whose 28 unmarked types need the same treatment, and `Cow<'_, [u8]>` has no alias spelling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Take a slice, not a Vec reference, in the resolution pass `clippy::ptr_arg` under CI's no-default-features run: the pass only mutates elements in place, so a slice is the honest signature. My local checks used --all-features only; CI runs three clippy configurations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * An out-parameter is a mode of borrowing, not a type `TypeKind::Uninit` wrapped a type, but uninitialized-ness is a property of the **borrow**: my own doc said `MaybeUninit` is "only meaningful behind a `&mut`", which is the argument against modelling it as a type at all. So `Ref` carries the mode, and the `MaybeUninit` is absorbed into it: Ref { mode: RefMode, inner: Box<TypeRef> } enum RefMode { Shared, Exclusive, Out } `&T`, `&mut T`, `&mut MaybeUninit<T>` — one axis, three values, and `inner` is always the borrowed *value's* type. One variant fewer than the `mutable` flag plus a wrapper, and the combinations that mean nothing at a boundary can no longer be written down: uninitialized storage owned, returned or in a field promises nothing a destination language can use, and `&MaybeUninit<T>` promises a readable `T` that may not be one. Both are refused, each naming why. `Out` rather than `Uninit` because it names the boundary role every destination language has — C's `T *out` — which is the fact an adapter acts on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: transitive closure, generic aliases, goldens, real index Four findings, all valid; two were mine in this PR. **Refusal was not transitive.** `resolve_references` snapshotted the initial declarations and validated everything against that fixed set, so refusing a type stranded its dependents: pub struct Broken { pub field: Missing } // refused pub fn use_broken(value: Broken) {} // survived anyway `Flat::resolve` then returned `None` for `use_broken`'s parameter, contradicting the one invariant the model promises. It now runs to a fixed point: each round drops the declarations it refused, and stops when a round refuses nothing. Chains of any length collapse, in either declaration order, because the declared set only ever shrinks — which is also why it terminates. Regressions cover the direct case both ways round, a four-link chain both ways round, a sound chain that must be left alone, and the invariant itself: every `Named` reachable from a surviving element resolves. **A generic type alias bypassed the binder refusal.** The `Item::Type` arm built an `Opaque` without calling `reject_generic_params`, so `pub type Handle<T> = hidden::Handle<T>;` was accepted as one declaration that `Handle<u8>` then resolved against — losing exactly the scoped-parameter distinction every other item kind refuses, and contradicting this PR's own argument that `MaybeUninit` needed grammar support *because* a generic alias is a binder. Type and const parameters are now refused; a lifetime binder stays accepted, as on every other kind. **The aarch64 goldens carried unrelated all-features output.** `git add -A examples` in the migration commit swept in pre-existing working-tree drift — `unstable_field`, `calculator_reset`, a non-empty feature guard — which is exactly the state 95fd753 had reverted, because committed aarch64 goldens represent a plain build. Restored from the base, and verified: a plain `cargo build --release -p example-cbindgen` on arm64 reproduces the base files byte-for-byte. CI is x86_64 and cannot see this pair, so it needed catching by hand. My "byte-identical" claim was wrong for that reason, not for the model changes. **`Flat` was not actually indexed.** It stored only a `Vec` and `element()` did `iter().find`, so every typed accessor and `resolve()` scanned — quadratic once later stages resolve in a loop, and not the "indexed by name" criterion L0.5 claims. Now a `HashMap<String, usize>` beside the elements: positions, so there is one copy of each element and source order stays available for iteration. Built after resolution, since refusing an item changes its kind but never its name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Brings in #221, #231 and #233. Three things worth knowing about the resolution. **Most conflicts were one change arriving twice.** #227's branch had been rebased onto #221 before it was squash-merged here, so the squash absorbed #221's diff — `git diff 225954a 0901651` over jnigen is empty. Merging main then replayed #221 as its own commit, conflicting with its own absorbed copy in nine files. For each, `git diff 225954a 989010e` showed this branch had added nothing beyond that copy, so main's side was taken wholesale: main is #221 plus #231 and #233 on top. **`Ledger` needed declaring.** #231 added it as an unmarked handle struct, which is exactly the drift the closure guard exists to catch — and it caught it on its first merge, naming all four referencing functions. It now takes the same treatment as every other handle here: the definition sits in the private `handles` module behind `#[prebindgen] pub type Ledger = handles::Ledger;`. `Report` keeps main's new `#[derive(Clone)]`, moved onto the definition, since the top-level name is only its alias. **Generated artifacts are regenerated**, because the merged tree's committed copies were a mix of both sides. The Kotlin and Rust output is the union of the two feature sets, and the boundary ledger is reseeded — `api/core/unfold.rs` 16 → 18, as #231/#233 added two classification sites there. Also fixed two strings the `language` → `flat` rename left stale: the ledger's own drift message and header still named `core::language`. Note for #231's author: `covertest-kotlin/build.rs` says "`Report` is not `Clone`, so cloning it here would not compile", while `ext.rs` now derives `Clone` on it and explains why it must. One of the two comments is stale on main; taken verbatim here rather than edited, since a merge should not quietly rewrite either side's prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in #234, `Registry::builder().source(dir)`. Two conflicts, both from the same cause: #234 removed the `Source` bindings from `covertest-kotlin`'s `main()`, and this branch had added the flat-API closure guard right there, built from those same bindings. `FlatBuilder` gains `source_named` — the follow-up #234 flagged, now needed rather than merely tidy. The guard reads its two directories directly, so the two builders stay shape-identical and no `Source` survives in that build script: Flat::builder() .source(perftest_flat::PREBINDGEN_OUT_DIR) .source_named(cov_helpers::PREBINDGEN_OUT_DIR, "cov_helpers") .build() That does read each directory twice, once for the guard and once for the registry. It is a build script and the cost is a second JSONL parse, and it goes away at L1 when the registry consumes `Flat` instead of re-indexing the stream — which is what the shared shape was for. `lib.rs`'s conflict was two export lists growing in parallel; both sides' names belong. The feature-coverage table at the top of covertest's build script now names `source_named` instead of the `Source::builder().crate_name()` it replaced. 519 tests, 18 doctests, clippy clean on all three configurations, generation byte-identical, and the JVM covertest still passes all 48 sections — including the renamed second source, which is the path this merge touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* A prelude, Extern instead of Opaque, and no args Three things the same question kept surfacing: what does the language know without being told, and what must it be told? **Path reduction had one rule and a std special case; now it has one rule.** `reduce_flat_path` already reduced `crate`/`self` and any source-module prefix — a path into the flat namespace collapses to its bare name. Bolted on was a five-entry whitelist of std paths. Naming what that whitelist is removes it: those are **aliases the language pre-declares**, a prelude in exactly Rust's sense. A crate need not write `use std::vec::Vec`, and need not write `#[prebindgen] pub type Vec = std::vec::Vec` either, for the same reason. So the mechanism is an alias map from path to name, seeded from `PRELUDE` and extended with every alias the ingested crates declared — because a prelude entry and a hand-written alias say the same kind of thing. That generalises past std: given `#[prebindgen] pub type Session = zenoh::Session;`, a signature may now spell `&zenoh::Session` and reach the declaration. `foreign::Option<u8>` is still not `Option<u8>`, because the key is the whole path, never a final segment. `Normalization` holds what to reduce against, replacing the module-gathering loop `FlatBuilder::build` and `Registry::from_items` each wrote separately — they cannot normalize differently now. Two traps found on the way. A marked alias must be excluded from the normalization it defines, or `pub type Duration = std::time::Duration` becomes `pub type Duration = Duration`. And the prelude's entries are *generic*, so an early "reduce only without type arguments" guard broke `std::vec::Vec<Foo>`; the guard was also unnecessary, since a full-path key cannot collide. `mem::MaybeUninit` joining the prelude is a bug fix. It was a grammar builtin that was **not** reducible, so it worked only because perftest-flat happens to `use` it; written `&mut std::mem::MaybeUninit<Payload>` it became an unresolvable nominal type and silently refused the item — and `maybe_uninit_inner`'s comment claimed normalization had already reduced it. One test row per prelude entry now pins both spellings to the same kind, which is how that class of drift gets caught. **`Opaque` becomes `Extern`, and carries what it points at.** It was never only handles: `pub type Duration = std::time::Duration` crosses by value through a `convert!`, erased to an integer. What the frontend knows is narrower and truer — this name is in the flat API and its contents are not modelled — and the adapter decides the rest. `target` is now a modelled fact, so an adapter can recognise `std::time::Duration` without taking syntax apart, and reduction uses it. Deliberately not classified as std-vs-foreign: `pub type Error = zenoh::Error` IS `Box<dyn std::error::Error + Send + Sync>`, so std-ness is a property of the spelling, not the type. A rule keyed on the path root would answer differently for one type depending on who aliased it. **`args` is gone from `Named`.** A reference is a name. Nothing could read retained arguments: a surviving reference resolves to a declared type, and no declaration takes type parameters, so `Foo<u8>` against a declared `Foo` would not compile in the source crate. They are still lowered, so a bad type inside one is diagnosed — the dropped test row asserted a shape real source cannot produce. The boundary ledger gains one site in `types_util` for reading an alias's target; L2 reclaims it when the frontend owns normalization outright. Generation is byte-identical and the JVM covertest passes all 48 sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Box the array extent, the size outlier among the kinds `clippy::large_enum_variant` under `-D warnings`: an `ArrayExtent` carries an `Origin` over its length expression, so `Array` towered over the second-largest variant once `Named` lost `args`. The lint compares those two, which is why shrinking one variant surfaced another's size. Boxed rather than allowed — an array is the rare kind, the same trade-off `Unsupported::error` already makes for the same reason. My local clippy runs missed it because they omitted `-- -D warnings`, so it was a warning my filter did not match. CI passes that flag in all three configurations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: an alias key is a whole type, and never shadows the grammar `path_key` dropped **all** generic arguments, so `type Bytes = std::vec::Vec<u8>` keyed on `std::vec::Vec` — overwriting the prelude entry, since the alias pass runs after the seeding. Reduction then swapped the ident and kept the use site's arguments, so `Vec<String>` became `Bytes<String>`, `Named` discarded the argument, and an unrelated parameter stopped being a `Sequence`. Any concrete alias could do this to any prelude entry. The root cause is a constraint I had not stated: normalization decides which spellings denote **one type** (issue #95, "the canonical flat-namespace spelling"), so it may choose a canonical spelling but must never change what a type *means*. `zenoh::Session` → `Session` preserves the kind. `Vec<u8>` → `Bytes` turns a sequence into an extern — retyping, not canonicalizing. Naming what the two kinds of alias are makes the fix structural rather than a patch. They **partition** the targets, because a target either has a grammar meaning or it does not: * the prelude, over targets the grammar models. Each names a **constructor**, so arguments are ignored when matching and preserved when rewriting — `std::vec::Vec<Foo>` is `Vec<Foo>`. * a crate's aliases, over targets it does not. Each names one **complete type**, so the key keeps type arguments (lifetimes still dropped, since a lifetime is spelling) and a match replaces the whole type — an alias name carries no arguments of its own. So an alias to something the grammar already models is not a reduction rule: the prelude owns that path. `type Bytes = Vec<u8>` stays a perfectly good name for an `Extern` — a bare path is never reduced, so `Bytes` resolves — while `Vec<u8>` keeps meaning a sequence and `Vec<String>` is untouched. Duplicate targets now resolve deterministically: first declaration wins, rather than last-in-stream. Two regressions, both verified to fail against the old behaviour before being kept: the reported case verbatim, and two concrete aliases over one foreign constructor staying distinct. The partition is documented where each half lives — the equivalence rule list and `Extern`'s own doc, including the asymmetry that an alias is an `Extern` always but a reduction rule only sometimes (`type Error = Box<dyn Error>` has no rule at all). Generation byte-identical, ledger unmoved, JVM covertest 48 sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * An alias is a one-way road, not an equivalence The review found that `alias_key` kept only `GenericArgument::Type`, so `type Small = zenoh::Wrap<4>` and `type Big = zenoh::Wrap<8>` still collided on their const arguments. Retaining every non-lifetime argument would fix that instance, but the key shape was never the real problem. Normalization decides which spellings denote **one type**. An alias does not create such a spelling — it brings a foreign type *into* the flat API under a new name. That is a one-way road: the name is thereafter the only way to spell the type here, and `zenoh::Session` in a signature stays refused even when `type Session = zenoh::Session` is declared. The diagnosis already said exactly that — "Give the type a name here with `#[prebindgen] pub type <Name> = ..;` and refer to that" — so alias reduction was weakening a rule the language already had. Treating it as an equivalence is a category error, and the two reported bugs are symptoms of it: `Vec<u8>` ≡ `Bytes` turns a sequence into an extern, and once one path can stand for two types, key shape decides which — arguments, const arguments, associated bindings, each a new way to collide. Removing the equivalence makes that class unreachable rather than patched. So a crate's `pub type` is a declaration only, and the prelude alone reduces: `std::vec::Vec<Foo>` is `Vec<Foo>`, because those *are* one type. The prelude and a crate's aliases stop being "two kinds of alias" needing a partition — different mechanisms with different jobs, which is the simpler answer to how they relate. Net −133 lines: `alias_key`, `type_args`, the alias map, the alias-collection pass, the first-declaration-wins tie-break, and the circularity guard that stopped an alias rewriting its own target all go. The boundary ledger returns to 205 — the site the previous commit added was reading an alias target, and nothing does that now. Nothing real depended on it: no marked signature in zenoh-flat or the examples spells a qualified alias target. Verified byte-identical generation and 48 JVM sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`zenoh-flat`'s `zbytes_to_bytes(z: &ZBytes) -> Cow<'_, [u8]>` was refused by the closed flat API, so it would vanish when that crate migrates. The cause was an assumption in `lower_path`'s guard — "a builtin generic takes types only; a lifetime argument on one is not a shape this language has" — which skips the whole builtin match when any lifetime argument is present. `Cow` is the counterexample it did not anticipate: a builtin generic whose own signature includes a lifetime. So `Cow<'_, [u8]>` fell through to an undeclared nominal `Cow` and the item was refused. **A `Cow` carries nothing a destination language can see, and both adapters already say so in code.** cbindgen: "`Cow<'_, [T]>` → `T_wire* + size_t`. The C side receives an owned malloc'd copy, just like `Vec<T>` outputs", and `type_contains_vec` groups the two. jnigen: `env.byte_array_from_slice(&v)` — `&Cow<[u8]>` derefs to `&[u8]`, so there is no Cow-specific conversion at all — yielding Kotlin `ByteArray`, exactly what `Vec<u8>` yields. So `Cow<'_, T>` classifies as `T`'s own kind, the `Box<T>` treatment, and no `TypeKind` variant is added: the semantic surface says nothing about a fact no destination acts on. What codegen genuinely needs is the *spelling* — jnigen rewrites its generated fn's param type to `::std::borrow::Cow<'_, [u8]>` because "the param type must be resolvable without imports" — and spelling already travels in `origin`. Classify off `kind`, spell off `origin`, with both adapters' existing behaviour now predicted by the classification instead of special-cased. Transparent for any target, as `Box` is. Whether a `Cow` can actually cross stays the adapter's call, and both already restrict — cbindgen to scalar slices, jnigen to `[u8]` — refusing the rest with their own diagnostics. `std::borrow::Cow` joins the prelude, for the reason every entry is there: a name no source has to import. It also stops the frontend being *stricter* than the adapters, which tail-match the last path segment and so accept a qualified spelling — the cbindgen fixture `cow_u8_returns_scalar_array` writes exactly that, which is the proof the qualified form occurs. Verified the three new rows fail against the old guard before keeping them. Generation byte-identical, ledger unmoved, 48 JVM sections. zenoh-flat is a separate repo, so `zbytes_to_bytes` is covered by an acceptance row rather than by a build. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Make every test fixture self-sufficient Preparation for L1, where `Registry` consumes `Flat` and an item naming a type the flat API does not declare stops being ingested. 167 of 524 tests held such an item; this makes them all declare what they name, verified against a temporary `#[cfg(test)]` check inside `from_items` that the next commit deletes. **`declare_referenced`** appends a marked alias for every nominal type a stream names but never declares, to a fixed point. Most fixtures are *about* a plan shape or a converter, and a handle declaration is noise in them — `reg_with(&["fn get(s: &Storage) -> Payload"])` is testing an unfold plan, not what `Storage` is. Declaring those as `Extern`s is what a real source crate does for a foreign handle, and it is inert either way: a type alias lands in no registry map. `reg_with` now parses `syn::Item`, so a fixture *can* declare its own types when that is the subject. Four things the helper cannot cover, each a real correction: **`std::time::Duration`** was spelled path-qualified in 15 places. A qualified name can never be a flat-API name, so those fixtures now declare `Duration` and spell it bare — the shape a real source crate uses. That moves the `TypeKey`, so the matching `convert!` and two generated-name assertions move with it. **Two array-length "qualification" tests** asserted that `Holder::N` and `array_len()` lengths get qualified. The subgrammar was narrowed to "an integer literal or the bare name of a marked const" in #212, so neither can reach an adapter any more; they survived only because `from_items` never validated lengths. Reduced to the form that can. (jnigen's qualifier still handles the dead shapes — removing that is L4's business.) **Three array-length rejection tests** move to `flat/tests/acceptance.rs`, where the subgrammar lives. They cover the dangerous family — `const {}`, `match`, `if let`, all of which bind a local that could shadow a marked item — and belong with the classification, not with jnigen. **Two registry tests are removed**, not edited: both assert that ingestion does *not* validate signatures, which is precisely what the next commit reverses. Their replacement lands there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Registry consumes Flat L1 of #229. `Registry::from_items` indexed the raw item stream itself, so the registry and `Flat` were two readings of one source that could disagree. The registry is now a **projection** of the model: `from_items` is `Flat::builder().items(..).build()` + `from_flat`, and the maps are arranged from elements the frontend already classified. The `Flat` is **held**, not discarded — `registry.flat()`. That is what makes the projection framing real rather than a slogan, and it is how L2–L4 reach the model: an adapter already has the registry. The maps stay owned rather than becoming live queries, because they are a projection *plus* synthesis: `resolve()` injects adapter-declared binding-local fns straight into `functions`. Projection rules worth stating, because two are asymmetries: * an unnamed `const _` — each source's injected `konst` guard — is the whole of `passthrough` now. The proc-macro refuses to mark a `use`/`mod`/`macro_rules!`, so nothing else ever reached it. * an `Extern` lands in **no** map. A type alias was already a no-op here, and keeping it that way is what holds generation byte-identical. It is reachable through `flat()` for the stages that will want it. **Ingestion now checks that the flat API is expressible.** A `self` receiver, an `async fn`, a generic binder, a type form outside the grammar, or a reference to a type the flat API does not declare fails the build — reporting **all** offenders at once, so a source crate that needs migrating sees one list rather than one rebuild per item. An opt-out for deliberately-unsupported elements is filed separately. That makes three registry guards unreachable, so they and their `ScanError` variants are deleted: `UnsupportedReceiver`, `UnsupportedParamPattern`, `DisallowedImplTrait`. The frontend's diagnosis is strictly richer — it names the parameter the bad type sits on. `index_item`, `check_no_duplicate` and `first_seen_loc` go with them: `Flat` owns both indexing and duplicate detection. `ParseError::DuplicateName` gains the two crate names, so the one authority produces the message that names both colliding sources. covertest-kotlin's hand-rolled closure assertion is removed — it was a stopgap for exactly this stage, and the registry now raises the same thing. Generation is byte-identical, the JVM covertest passes all 48 sections, and the boundary ledger drops to 204 (the deleted `impl Trait` classifier). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Record L1 in the stage map Ticks L1 and records the decision that supersedes its original wording: an item the language cannot express fails ingestion rather than staying inert until declared. Also notes the measured fixture cost and what is still open — zenoh-flat's 26 unmarked aliases, which block its two consumers until marked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Point the L1 note at the filed opt-out issue Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: validate the one input that bypasses Flat **The diagnostics regression (review 2.1).** `resolve()` inserts `adapter.local_functions()` straight into `self.functions`, so a `sig!(..)` written by hand in a build script never touches `Flat`. Deleting `scan_fn_signature`'s receiver and pattern guards therefore did not merely move those checks — it removed them for that input. `sig!((self, x: u32) -> Ret)` would `continue` here, `continue` again in `fn_plan`, and drop the parameter silently; the user would meet it as an arity mismatch out of rustc on generated code. Fixed where the reviewer suggested, at synthesis rather than back in `scan_fn_signature`: `Flat::check_signature` runs the frontend's own `lower_fn` over a local fn, so the grammar stays decided in one place and the check sits on the one input that bypasses it. Grammar only — whether a local fn's types are *declared* is a whole-model question, and a binding-local fn may legitimately name types the source crate never did. Both halves are tested. The two "cannot reach here" comments now say why, naming both paths. **The report loses the crate (review 1.2).** A captured path is crate-relative, so two offenders read `src/lib.rs:0:0` and the location alone cannot say which crate to fix — exactly why this PR added crate names to duplicate-name diagnostics. `NotExpressible` now renders `in crate `x`` using the same `in_crate` phrasing, with a two-source test whose offenders share a file path. The trailing newline is gone with it. `DeclaredNotFound` and `QualifiedDeclaredTypes` have the same trailing-newline shape and are left alone as pre-existing. **`Registry::default()` (review 2.2).** A registry built that way projects nothing, so `flat()` would hand a later stage an empty model claiming to be its source. The `Default` impl becomes `pub(crate) fn empty()`: outside the crate the entry points are `from_items`, `from_flat` and `builder`, each with a model behind it. Nothing required the bound; in-tree fixtures were the only callers. **Flat's docs promised the opposite (review 1.1).** They said an `Unsupported` element stays inert until an adapter declares it, which this PR supersedes. Rewritten around the actual split — **parsing diagnoses, ingestion raises** — which is what lets one model serve both a consumer inspecting what a crate marked and a binding that must be built against a model read in full. Four sites, including `Element::Unsupported` and `Flat::unsupported`. **Untested behaviour changes (reviews 1.3, 2.3).** `from_flat` had no direct test; everything reached it through `from_items`, which cannot tell "the projection is right" from "parser and projection are wrong in matching ways". Added one asserting every element kind's destination, that the model is kept, and — the change the reviewer caught — that an `Extern` now records an origin where the old `syn::Item::Type` no-op recorded none, so a helper-crate alias qualifies against the helper crate instead of the default module. Generation byte-identical, 524 + 452 tests, covertest 48 sections. The local-fn guard was checked against its own removal and fails as it should. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* The type table carries Flat's reading of each type
L1 made `Registry` a projection of `Flat` for the item maps. The **type table** —
what generation actually runs on — still threw the frontend's work away, keying
cells by a normalized `syn::Type` with the classification deleted. That deletion
is why `types_util` exports `is_option_type` / `option_inner_type` /
`result_parts` / `bare_path_ident`: 144 uses outside that one file, all
recomputing what `Flat` decided.
`TypeEntry` itself had nothing to reuse and is unchanged in spirit: `destination`,
`function`, `pre_stages`, `niches`, `metadata` are the adapter's answer, not the
source's meaning. The reuse is one level up, in the cell the entry hangs off.
input_types: HashMap<TypeKey, TypeCell<M>>
TypeCell { subject: TypeSubject, root: bool, entry: Option<TypeEntry<M>> }
TypeSubject::Source(TypeRef) | TypeSubject::Adapter(syn::Type)
An enum rather than an `Option<TypeRef>` beside a location, because a type the
flat API contains **is** a `TypeRef` — classification and origin together — and a
type only the binding authored has no reading and no source location. That is a
fact about it, not information that went missing.
So `type_locations` is deleted: `TypeRef.origin.location` is it. The old map was
worse than duplicated, it was circular — the declared-type path read a key's
location back out of the map it was about to write, falling back to
`SourceLocation::default()`. With one origin per cell the whole `loc` parameter
threads out of `ensure_entry`, `scan_fn_signature`, `scan_struct`, `scan_enum`,
`register_type_*`, `require_input` and `require_output`.
**The readings come from the model, not from lowering twice.** `Flat::type_refs`
walks every type the API mentions — the new accessor, distinct from `types()`,
which is every type it *declares* — and `from_flat` indexes it before anything is
scanned. `ensure_entry` then looks a key up. Keying by type rather than threading
positionally is what makes it right for generics: `TypeId` carries no arguments,
so `MyBox<Foo>` has no `Foo` child in its `kind`, yet the registry's walk emits a
`Foo` sub-key — which finds its reading from wherever else `Foo` appears.
`first_unresolved`'s per-element slot enumeration became `element_type_refs`, so
the slots are listed once and `type_refs` cannot drift from the resolver.
**`required` stops being stored.** It was one name over three storages, and two
facts: *is a root* (a scan fact) and *is reachable from a root through the
adapter's `subs`* (a derivation). The old code wrote the derived answer back into
`TypeEntry::required` **and** `required_*_scan`, which already held the root fact.
Now the cell keeps `root` and `resolve::required_set` returns the reachable set
for `final_invariant_check` to consume. Gone: `required_inputs_scan`,
`required_outputs_scan`, `TypeEntry::required`, `propagate_required`,
`set_required`, `is_required_resolved`, `mark_and_get_subs`,
`is_required_*_at_scan`, `lookup_slot`.
`root` stays a field rather than folding into `TypeSubject` because the axes are
independent — all four combinations occur. `Source + root: false` is the bulk of
the table (every nested position, every field type), and `Adapter + root: true` is
what `required_output_types` is for.
`immediate_edges` reads a declared type's fields off the element
(`flat.declared_type`) instead of `syn::Fields::Named`, which silently skipped
positional fields. Same edge set today — a tuple struct is an `Extern` and
declares none — without the asymmetry. Its fallout in tests was a fixture that
hand-inserted into `reg.structs` while leaving `flat` empty; it now drives the
real scan, which is the state the pipeline can actually produce.
**Measured**: 3 `Adapter` cells out of 342 across the four examples —
`Option<Summary>` and `Result<Summary, String>` (shapes the adapter composes) and
`MaybeUninit<Payload>`. The last is the evidence for a deferral: flat absorbs
`MaybeUninit` into `RefMode::Out`, so that bare node exists only in the registry's
syntactic walk — which is why `immediate_subtype_positions` is not yet replaced by
a `TypeKind`-children walk. The `Option` earns its keep.
The ledger does not move. This makes the classification available; taking callers
off raw syntax is L2's own work.
Generation is byte-identical, 523 + 451 tests pass, covertest-kotlin runs all 48
sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* `const _` is a Guard, not a Constant
The injected feature check was modelled as a `Constant` whose name happens to be
`_`, and every consumer that must not treat it as API re-checked that sentinel.
Five sites did, across four files.
Two facts make the sentinel wrong rather than untidy:
**The guard is not a captured item.** `Source`'s cfg filter *synthesizes* it
(`api/batching/cfg_filter.rs:143`) — one per ingested crate, asserting that
crate's `FEATURES` match what the build script asked for. Nothing in the source
crate marked it, so it was never part of the flat API, which is the set of things
a `#[prebindgen]` crate declares.
**Four of the five checks were already dead.** Once L1 routed unnamed consts away
from `consts`, `write.rs`'s const gate, the skipped-const warning, and both
`on_const` implementations guarded a state the pipeline could no longer produce.
That is the failure mode a sentinel invites, and it had already happened.
So:
Element::Guard(Guard { origin: Origin<syn::ItemConst> })
Named for what it **is** — a compile-time assertion protecting the generated file
— not for what a consumer does with it. `Element` classifies; `Passthrough` would
name an emission strategy, and that variant was deliberately deleted earlier in
this program.
Recognised by **shape**, not provenance: a constant with no name has no address,
so nothing can declare it, reference it, or emit it as an alias. That is the
property that makes it infrastructure and it holds whoever wrote it — so no new
ingestion channel is needed and today's behaviour is preserved exactly.
It carries **no `TypeRef`**. The item is emitted verbatim, so what its types mean
is the consumer crate's business. Today the guard's `()` is lowered and does
participate in `first_unresolved`, so a guard naming an undeclared type would turn
the whole element `Unsupported` and — post-L1 — fail the build. `()` is `Unit`, so
that never bit; dropping the slot removes the coupling.
`Element::name` loses its `.filter(|id| *id != "_")`, which existed for this alone.
`Registry::passthrough` becomes `guards: Vec<Guard>` — the bucket's one occupant
now names it. Emission is unmoved: last in `write_rust`, in stream order.
One `"_"` comparison stays, in `flat/mod.rs`'s Pass 1: the `ConstIndex` an array
extent resolves against is built before Pass 2 classifies anything, so it has only
raw items to filter. It is the one site that cannot read a classification, and now
says so.
The module doc's "no verbatim passthrough" claim is **amended, not reversed**: no
*marked* item passes through, and the one item that does was never marked.
Generation byte-identical (the two aarch64 goldens drift identically to the base
branch — the known `--features unstable` mismatch), 524 + 452 tests, covertest
48 sections. Both new tests were checked against a reverted classification and
fail as they should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Address review: state the contract the classifier actually enforces
**The docs overclaimed (review 1).** `lower_item` classifies *any* anonymous const
as a `Guard` — a hand-fed `FlatBuilder` item, a user-written `#[prebindgen] const
_: ..` — but the docs said "prebindgen's own injected checks" and "one feature
guard per ingested source crate". Both cardinality claims are wrong, and I checked
rather than assumed:
* `enable_feature_filtering(None)` leaves `features_constant: None`, so
`build_cfg_filter` skips the guard entirely — **zero**;
* `items_all` / `items_in_groups` / `items_except_groups` each build a *fresh*
`CfgFilter` with `prelude_emitted: false`, so composing two iterators from one
`Source` yields **two** guards from one crate.
Keeping the shape rule, which was the deliberate choice, and making the docs say
what it means: a `Guard` is an **anonymous const**, defined by having no address
rather than by who produced it; the feature check is documented as today's
producer rather than the definition; cardinality is **zero or more**. Six sites,
including `Guard`'s own doc, `Flat::guards`, `Registry::guards` and `write.rs`.
**Emission was untested (review 2).** `a_guard_never_reaches_the_const_surface`
proves the maps are separate but never calls `write_rust`, so nothing caught a
change that keeps `Registry::guards` populated and then drops or re-gates it on
the way out. `guards_emit_ungated_and_in_stream_order` declares an *empty*
`declared_consts()` gate with one named const and two distinguishable guards
straddling it, and asserts the named const is gated out while both guards emit in
order. Checked against both failure modes — emitting none, and emitting reversed —
and it fails on each.
**The doc contradiction (review 3).** `from_items` listed `guards` among the maps
and then said undeclared items "never emit", which is the opposite of what a guard
does. Now says an *API* item behaves that way and names `guards` as the exception
that is outside the gate because it has no name to declare. The core module
overview's stale "passthrough items" goes with it.
Also fixed three unresolved intra-doc links introduced across this stack (one
here, two in the type-cell commit) — no CI job gates on them, so they are fixed at
the tip rather than by another rebase. Warnings 17 → 16 against the L1 baseline.
529 + 457 tests, clippy clean in three configs, generation byte-identical,
covertest 48 sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Flat is the only index; Registry stops keeping a second one
`Registry` held five maps — `functions`, `structs`, `enums`, `consts`, `guards` —
plus `item_origins` and `source_modules`. Every one was built in `from_flat` by
walking `flat.elements()`, and every one duplicated something `Flat` already had,
indexed the same way. L1 made the registry a projection of the model; a projection
that copies is still two stores that can disagree.
All seven are deleted. `from_flat` is now the expressibility check, the type-ref
index, and storing the model — nothing else.
Two facts found by measuring the call sites first:
* **The `SourceLocation` half of every entry was dead.** All 44 `.get()` sites
destructured `(item, _)`; every `values()` and index site bound `_loc`. Nothing
had read it since L1 moved locations onto elements.
* **`guards` had one reader** and was already `Vec<flat::Guard>` — the flat type,
copied out of the model verbatim.
`Flat` grows what the maps were providing, beside its existing typed accessors:
`struct_type`, `enum_item` (either enum shape — the merge the old `enums` map made,
which 30 adapter reads depend on), and `source_modules`. `Registry` keeps
`origin_module`, `default_module`, `all_source_modules` and `named_item_idents` as
methods — they are questions, not storage — now answered off `flat`. **No mirrored
accessors on `Registry`**: one door, so there are not two interfaces to keep in
agreement.
**Binding-local fns move into the model.** They were the one population `Flat` did
not have — a `sig!(..)` is written in a build script and was inserted straight into
`registry.functions` — so deleting that map would have left `flat.function()`
incomplete and "one index" a lie. `check_signature` already lowered one through
`lower_fn` and discarded it; it returns the `Function` now, and a `pub(crate)`
`add_local_function` admits it with the adapter's origin crate stamped where
`origin_module` already looks. The public surface does not grow.
Three invariants would have moved generated output silently. Each is now tested,
and each test was checked against its own violation:
* `named_item_idents` must keep excluding `Extern`. Its caller decides which names
generated Rust qualifies, so including an alias would move output.
* `source_modules` must not see binding-local fns — it decides `default_module`,
which is what an unqualified reference resolves against. Fixed by construction:
`Flat` freezes it in `build()` from the captured stream, and
`add_local_function` does not touch it.
* `item_origins` must keep seeing them — the mirror of the above, and what
qualifies a local fn's generated call.
**Ledger 204 → 202**, the first movement in this program: `accessor_signature` and
`accessor_consumes` peel a borrow by reading `TypeKind::Ref` instead of matching
`syn::Type::Reference`. `ctor_signature` and two return-type walks likewise read
`params`/`ret` off the element rather than re-deriving them from the signature —
which also drops three hand-rolled copies of "an elided return is `()`", a fact the
model states once.
Generation byte-identical **with `cargo clean -p example-cbindgen -p example-flat`
first** — the check only regenerates what cargo decides to rebuild, so a cached run
proves nothing. 589 + 517 tests, covertest 48 sections, clippy clean in three
configs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Spell the negated lookups as `is_none`
`!x.is_some()` from the mechanical rewrite. `clippy::nonminimal_bool` on the MSRV
toolchain rejects it; the newer clippy I had been checking with does not, so this
reached CI.
The gap was in the verification, not the code: CI's clippy step is
`--no-default-features --all-features` **together** and runs on 1.85.0, and I had
been running the two flags separately on nightly. `cargo +1.85 clippy --all-targets
--no-default-features --all-features -- --deny warnings` reproduces it exactly and
is now clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* A lookup takes the name the caller already holds
Making `Flat` the only index left every lookup spelling its argument
`&x.to_string()` — 73 call sites — because the accessors take `&str` while callers
hold a `syn::Ident`.
One fact from `proc_macro2` decides the fix, and it is the opposite of what the
obvious move suggests: **the allocation cannot be removed, only moved.**
`impl Hash for Ident` hashes via `self.to_string()`, so re-keying `by_name` as
`HashMap<syn::Ident, _>` would allocate on every lookup *and* every insert, and
would make the `&str` callers start paying too. `Ident` has no `Borrow<str>` and no
`as_str()`, so no borrow-based path exists either.
So this is call-site noise, not cost, and it belongs in the API:
pub trait Name: sealed::Sealed { fn as_name(&self) -> Cow<'_, str>; }
impl Name for str // Borrowed — free
impl Name for String // Borrowed — free
impl Name for syn::Ident // Owned — the allocation, moved inside
impl<T: ?Sized + Name> Name for &T
The six accessors — `element`, `function`, `declared_type`, `constant`,
`struct_type`, `enum_item` — take `&N: Name + ?Sized`.
`Cow` rather than a simpler `impl Display` + `format!` because two callers must stay
allocation-free: `immediate_edges` runs per type-graph edge across both the scan and
the resolver's BFS, and `Flat::resolve` runs per reference. Both hold a `String` or
`&str` and keep `Cow::Borrowed`.
The blanket `&T` impl is what let the migration be one mechanical rule
(`&X.to_string()` → `&X`): without it, the sites where `X` is already a reference
would have produced `&&Ident`.
Sealed, so the one new public name cannot grow a second meaning from outside. That
is the trade against the alternative — a `*_by_name` twin for each accessor, twelve
names instead of seven, two spellings per concept to keep in agreement.
Signature change only: no generated byte and no test assertion moves. The call sites
lose 22 net lines; `flat/mod.rs` is the only file that gains any. Verified with
`regen-check` after `cargo clean -p example-cbindgen -p example-flat`, `cargo +1.85
clippy --all-targets --no-default-features --all-features`, 589 + 517 tests, and
covertest's 48 sections. A doc-test on `Name` pins that both spellings reach the
same element.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Address review: docs, one alias answer, and no self-inflicted expects
**1. Stranded docs.** `declared_type_idents` landed between `named_item_idents`'
doc block and its signature, so a private helper carried three stacked blocks while
two public methods carried none — and `origin_module`'s doc had already been
stranded there before this branch. Each doc now sits on its own method. The
inherited text also still described `item_origins`, which this PR deletes; that
sentence is gone, and the alias exclusion is stated where the arm performing it can
be seen.
**2. Two sibling checks disagreed about an alias.** `scan_declared_items`'
path-qualified warning became `declared_type(..).is_some()`, which answers `Some`
for an `Extern`; the `ignored_types` check sixty lines down kept
`struct_type(..) || enum_item(..)`, which does not. Both were `structs || enums` on
the base, so I had changed one and not the other.
Chosen answer: **an alias does not count**, restoring both to the base's behaviour.
Firing is arguably more correct — an alias *is* a captured item declaring that name
— but this PR claims to move no behaviour, and that claim is what makes
`regen-check` meaningful as its proof. A warning that starts firing is still a
change, and it belongs in a PR that argues for it and tests it.
Both sites now share `declares_type_body`, so they cannot drift apart again.
**3. Three self-inflicted `expect`s.** Each loop collected `Vec<&syn::Ident>`,
sorted, then looked every name back up — manufacturing an infallible lookup the type
system could not see was infallible. They hold the elements instead
(`Vec<&Function>` / `Vec<&Constant>`, sorted by `name`), which deletes the `expect`,
a second hash per iteration, and a `to_string()` per iteration. Ordering is
unchanged: `Ident: Ord` is the string order. This restores the standing rule that
the working path carries no `expect`.
**4. `__f` / `__s` / `__c` closure bindings**, 29 sites, artifacts of the mechanical
rewrite dodging an outer `f`/`s`. Now `func` / `st` / `konst`. The 13
`__e`/`__v`/`__x` are pre-existing and left alone.
**5. `check_signature` → `lower_signature`.** It returns the lowered `Function` and
the caller keeps it; the name should say lowering-that-validates rather than
checking.
Re-applied on top of the #244 merge rather than rebased: #244 rewrote most of the
same lines, so replaying produced 13 conflicts against a branch whose content I
could reproduce exactly. `declares_type_body` needs no `to_string()` here, since
`Name` landed with #244.
Byte-identical generation (after `cargo clean -p example-cbindgen -p example-flat`),
589 + 517 tests, covertest 48 sections, `cargo +1.85 clippy --all-targets
--no-default-features --all-features` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* An alias counts as a declaration of its name
The follow-up #243's review asked for: there, both type-diagnostic sites were
restored to `structs || enums` because that PR's claim was that it moved no
behaviour. This is the change on its merits.
Two sites ask "does the source declare a type under this name?":
* the **path-qualified** heuristic — `ptr_class!(foreign::Handle)` warns "a
captured item `Handle` exists — declare it by its bare name";
* the **ignored-type** check — `ignore_types(Handle)` warns "not found among
`#[prebindgen]` items".
Both answered "no" for an alias, and both were wrong to. `#[prebindgen] pub type
Handle = ..` **is** a declaration of the name `Handle`, and an adapter may declare
it bare — that lands in the no-indexed-body branch, which is exactly what
`ptr_class(ZKeyExpr<'static>)` relies on. So the first suppressed a fix-it that
would have worked, and the second called a captured item missing.
The exclusion was never a decision. It is an artefact of where the answer used to
come from: the pre-`Flat` code asked the `structs`/`enums` maps, which never held
an alias because the registry had no map for one. #243 moved the lookup to the
model and the artefact became visible.
`declares_type_body` → `declares_type`, and it is `flat.declared_type(..).is_some()`.
**`declared_type_idents` deliberately keeps excluding aliases.** It is the sibling
that looks like it should change and must not: it feeds *"skipping undeclared
`#[prebindgen]` struct/enum"*, which asks what an adapter left unclaimed and names
a kind an alias is not. Warning about unclaimed aliases may be worth doing, but it
needs its own message and is a different question. Both halves are pinned by the
test, and both were checked against their own violation.
**Nothing in-tree exercises this.** The four example crates emit 251 of these
warnings and the set is byte-identical before and after — measured, not assumed.
So the tests are the only proof, and they construct the case directly rather than
leaning on the examples.
The warning *text* is `cargo:warning=` on stdout and is not captured; what the
second test pins is that an alias reaches both sites through `scan_declared`
without tripping the `QualifiedDeclaredTypes` hard error. Said plainly rather than
claimed as coverage it does not have.
591 + 519 tests, generation byte-identical after a forced rebuild, covertest 48
sections, MSRV clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Name the body-only helper for its population
`declared_type_idents` read as the iterator form of `declares_type`, which it is
not: the predicate counts every declared type including aliases, the iterator
excludes them. Review's point — the pairing invites exactly the accidental
widening the rest of this PR documents against.
`struct_enum_idents` names the population instead, and matches word-for-word the
warning it feeds ("skipping undeclared `#[prebindgen]` struct/enum"), so the
reason for the exclusion is visible at the call site. The doc says outright that
it is not the iterator form of the predicate.
The existing test already calls the helper directly, so the clearer name is pinned
too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Point the JNI docs at the model, not the deleted maps
Five comments still named maps this PR removes, so the documentation described an
architecture that no longer exists:
* `jni/classify.rs` — "`registry.structs` probes" → `registry.flat()` type probes,
which is what `type_kind` actually does
* `jni/mod.rs` ×2 — `registry.functions[ident]` → `registry.flat().function(ident)`,
matching the lookup `kotlin_emit`/`symbols` perform on a `FunctionEntry`
* `jni/emit/struct_out.rs` — "`registry.structs`" → the parsed model; the claim
"populated before `resolve`" still holds, the model more plainly than the maps did
* `jni/trait_impl.rs` — the fourth the review did not name: it explained the
default-module fallback in terms of "items `item_origins` never sees", and
`item_origins` is gone. Restated as what the fallback now turns on — an element
whose location carries no crate name.
Each new claim was checked against the code rather than assumed: `classify.rs:67`
probes `flat().struct_type`, the `FunctionEntry` lookups are
`flat().function(&entry.rust_ident)`, and `struct_out` reaches the model through
`ext.type_kind` (indirect, as the original comment also was).
Docs only — no code, no behaviour. 591 tests, generation byte-identical after a
forced rebuild, MSRV clippy clean, doc-link warnings unchanged at 16.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Flat owns the type index too `Registry::type_refs` was a `HashMap<TypeKey, TypeRef>` built in `from_flat` by walking `flat.type_refs()`, with one consumer: `ensure_entry`, deciding whether a cell's subject is the frontend's reading or an adapter-authored type. An index over `Flat`'s own content, held outside `Flat` — and `Flat::type_refs()` had no other caller, so the public iterator existed only to feed it. #243 made `Flat` the only *item* index; this was the last one left. `Flat` gains `by_type` and `type_ref(&syn::Type) -> Option<&TypeRef>`, so **`from_flat` collapses to what it always should have been**: check expressibility, store the model. Everything still in `Registry` is now genuinely its own — the two type tables and the five adapter-declared plan maps. **A binding-local fn's parameter types are now indexed**, which the old ordering got wrong: the index was built in `from_flat`, local fns are inserted later by `resolve`, so their types missed it and their cells came out `Adapter` — "no frontend reading" — though `lower_signature` had produced `TypeRef`s for them. `add_local_function` feeds the index. Deliberately unlike `source_modules`, which stays frozen because it decides `default_module` and would change how *captured* items are qualified; this only makes a cell tell the truth about a reading that already exists. **One definition of canonical spelling.** Two things must agree on what a type is called — this index and `TypeKey`. Adding a second copy of "prelude-normalize, then token string" would have made that worse, so it moved to `types_util::{canonical_type, canonical_spelling}` and `TypeKey::from_type` now derives from it. Fewer definitions than before, not more. Generated output is byte-identical and covertest passes 48 sections — reported because it is worth knowing, not because it was a design constraint: an architecture change whose output moves without changing semantics or performance would have been equally fine. 592 + 520 tests. The new one covers the case the local-fn fix exists for and fails when `add_local_function` stops feeding the index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * An absent source position stays absent in diagnostics Review found a failure-path regression this PR introduced, and reproducing it against the base showed it was two faults, not one: | | base | this PR before the fix | |---|---|---| | a type only a local fn writes | `error: …` ✓ | `:0:0: error: …` ← the regression | | a captured item with no position | `:0:0: error: …` | `:0:0: error: …` ← pre-existing | `lower_signature` lowers a `sig!(..)` against `SourceLocation::default()` — `Origin` requires a location and a build-script signature has no file. Indexing those types flipped their cells from `Adapter` to `Source`, and `TypeSubject::location()` returned the default unconditionally, so the diagnostic printed a position that reads as real. The same fault already showed for any hand-built stream, whose captured items carry default locations too. **Having a reading and having a reportable position are different facts.** The classification fix stays — those types genuinely do have readings — and `SourceLocation::has_position()` names the other one, on the type that owns the question. `TypeSubject::location()` filters on it, which fixes the regression and the pre-existing case through the same path. The test pins both directions: a local-only type reports without a position while still being reported at all, and a captured item with a real position still prints `src/lib.rs:12:3`. It fails if the filter is removed **and** if `has_position` starts answering `false` for everything. Also: `Flat::type_refs()` is deleted rather than re-documented. It was added in #239 to feed the registry's index, that index now lives inside `Flat`, and it has zero callers — a public iterator whose docs told consumers to build exactly the map `type_ref` now is. `index_types_of` walks `element_type_refs` directly, so nothing depended on it. 593 + 521 tests, byte-identical generation, covertest 48 sections, MSRV clippy clean, doc-link warnings unchanged at 16. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The map had drifted from the code on almost every concrete claim, and it is the
document the umbrella PR mirrors — so the umbrella was wrong too.
**Names and shapes.** `core::language` → `core::flat`, and the design section
described structs that no longer exist: `Type { kind, syntax }` is
`TypeRef { kind, origin }`; `Param` carries an `Origin`; `Variant` is a sum with
`alternatives` while the C-style `Enum` is a separate entity with `values` — the
old single `Variant { tag, discriminant, fields }` conflated both. The
where-does-each-fact-live table pointed at fields that were renamed or split.
**L0's own checklist was wrong about L0**: it listed a `Passthrough` variant that
#227 deleted during that stage. Restated at the level that survived, with the
variant list left to L0.5 where it is accurate.
**L0.5 claimed two things that later changed**: `MaybeUninit<T>` became
`RefMode::Out`, not `TypeKind::Uninit` — an out-parameter is a property of the
borrow, not a wrapper type — and `Cow<'_, [u8]>` is no longer open, since #236
made it transparent like `Box<T>`. `Duration` remains genuinely open.
**L1.5 is new**, and recording it is the point of this commit: #239–#246 were not
a planned stage, they fell out of reviewing L1, and the map should show where the
program went rather than where it was aimed. Seven registry fields deleted, the
type table carrying the frontend's reading, `Guard`, `Name`, aliases counting as
declarations, and the type index moving to its owner.
**The numbers are re-measured, and one of them is unflattering**: the ledger is
still **202**. L1.5 deleted 113 map reads but took only two classifiers off the
ledger. Saying "still 202, the ledger has not started falling" is the honest
report; L2 is where it does. Per-area counts corrected (`api/core` 71, `jnigen`
106, `unfold` 16, `registry` 11).
**L5's first bullet is already done** — L1.5 deleted the public item maps outright.
**The review protocol is rewritten.** It said byte-identical artifacts were a
gate and "a diff is a bug". That is backwards, and I had been applying it: in #243
it argued me out of a correct fix because the fix would have emitted one extra
`cargo:warning=`. `regen-check.sh` is instrumentation — it says what moved, not
whether the change was allowed. Output that moves without changing semantics or
performance is fine, and no architecture decision may be reshaped to keep bytes
matching. The protocol now also states how to run the check so it means anything:
clean `examples/` first, then `cargo clean` the two crates, because it only
regenerates what cargo rebuilds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bring the stage map up to what the program actually did
|
Body re-synced from The map had drifted from the code on nearly every concrete claim, so this is a correction, not a status bump:
Two worth calling out rather than leaving in the diff: The ledger is still 202. The 113 registry map reads are gone — L1.5 deleted the maps — but only two classifiers came off the ledger. The doc now states that plainly instead of implying progress that has not happened. L2 is where it starts falling. The review protocol said the opposite of the rule. It read "byte-identical artifacts… a diff is a bug", and that rule had real consequences: in #243 it argued me out of a correct fix, because the fix would have emitted one extra Next stage is L2 — |
…atched (#248) * Delete the pattern engine; the model already names the one shape it matched The registry could compose converters for any parametrized type: a four-rank wildcard table, a general unification engine, `Foo<_, _>` patterns at any depth. The universality carried no traffic. **The table had one entry, in the whole crate:** ```rust // builder.rs — the only insert into either table let pattern: syn::Type = syn::parse_quote!(Result<_, _>); jni.output_wrappers[2].insert(key, ..); ``` `input_wrappers` was **never** inserted into, so `match_user_input` always returned `None`; the rank-1 lookups heading `input_wrapper_shape` / `output_wrapper_shape` were always-`None` prologues to their real hardcoded logic; and no public API could register a pattern. The code's own comment said it: *"The rank tables are internal — this is their only entry."* And `Result<T, E>` is `TypeKind::Fallible` in the model. A unification engine expressed one fact the frontend states outright. Gone: `match_pattern`, `unify`, `immediate_pattern_children`, `wildcard_count`, `lifetime_eq`, `token_eq`, `substitute_wildcards`, `ordered_patterns`, `ordered_input_patterns`, `ordered_output_patterns`, `match_user_input`, `match_user_output`, `WrapperFn`, and both rank tables. `lookup_input` / `lookup_output` lose their `pat`/`args` parameters — with the tables gone they answer only for `convert!`, which was always their only live path. **The `ConverterImpl` tail is extracted, not rewritten.** Terminal-vs-composed detection, exception binding and metadata assembly are the subtle part, so `build_output_converter` holds them verbatim and both survivors call it: the `convert!` path with `arg0: None`, the `Result` peel with `Some(ok)`. That mapping is exact — the old `rank == 0` tested precisely "no peeled inner". **Measured, not assumed:** the syntactic fallback in `fallible_parts` **never fires** — zero occurrences across covertest-kotlin and perftest-kotlin, because #246 indexes a binding-local fn's types, so even `sig!((..) -> Result<Summary, String>)` has a reading. It is kept rather than made a hard error, since an out-of-tree consumer may compose a `Result` the model never sees, and it costs nothing: `result_parts` already existed with six other callers. **No new test.** The plan called for one pinning the peel; the existing suite already does, verified by sabotage — removing the peel fails **8** tests across `snapshots`, `flatten`, `sealed` and `cross_artifact`. Adding a ninth would be decoration. **Ledger 202 → 167** (`types_util` 40 → 14, `jnigen/builder` 13 → 4) — a drop of 35, and the first real fall in this program: L1.5 moved 113 map reads but took only two classifiers off. Generation byte-identical, covertest 48 sections, 590 + 518 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Stop documenting the dispatch that was deleted Review's point: the PR's purpose is architectural deletion, so leaving the old model in adjacent documentation makes the surviving code harder to read. **Four broken intra-doc links**, all confirmed by `cargo doc --document-private-items`: `[`match_pattern`]` in `types_util`, `[`WrapperFn`]` in `builder`, and `[`Self::input_wrappers`]` / `[`Self::output_wrappers`]` in `mod`. Each is rewritten to describe what is there now rather than repointed — the lifetime rule stands on its own reason, `lookup_input` answers for `convert!`, and terminal dispatch is opaque → enum → `convert!` → primitive → struct. **Prose that still described a table with no writers**: "the user-wrapper table (`match_user_*`, any depth, specificity-ordered)", "the rank-0 user table", "the rank-1 user table" ×2, "the unified user-registered wrapper table", "before the wrapper tables", and a comment explaining how to override `Result<_, _>` by registering a more specific rank-1 pattern — an instruction for an API that no longer exists. **The ledger's blind-spot list** named `match_pattern` as a classifier the check could not see. That gap is now closed rather than open, and the header says so instead of listing it. Surviving "rank-0" mentions are left alone deliberately: in the adapters they read as "terminal, not composed", which is still true and is a property of the converter rather than of the deleted table. **On the removed public API** — `types_util::match_pattern` and `wildcard_count` were `pub`. They are absent from the released `0.4.1`, so this only affects someone tracking `language-integration` directly; noted here since the crate's 0.5 policy is a new surface with no back-compat shims. Docs only. 590 tests, generation byte-identical, MSRV clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * The caller states its declarations; the registry stops asking `DeclaredItems` was a private 13-field struct assembled by `DeclaredItems::from_adapter`, which called **twelve getters** back into the adapter — backed by twelve trait methods — from *inside* `resolve`. That round-trip was ceremony. `JniGen` already holds this state; the getters projected it out one method at a time and `from_adapter` copied it into a struct. The struct **is** the registry's construction input. Nothing was learned by asking for it piecemeal. So the struct is now `pub struct Declarations` with builder methods, the twelve trait methods are one: fn declarations(&self) -> Declarations; and `from_adapter` is gone — `adapter.declarations().check()?` replaces it, with `check` keeping the two conflict rules (a name both declared and ignored) that `from_adapter` enforced. **Why this matters beyond the line count.** Assembling declarations *inside* `resolve` is what made "configuring" and "using" the same call, which is what lets a converter be handed a half-built registry, which is why `None` from `on_input_type` is ambiguous between *defer* and *cannot* — and that ambiguity is the only reason the fixed-point loop exists. Stating declarations before resolution is the prerequisite for computing a resolution order at all. Two measurements say the rest is derivable, so S2b can compute that order: * the five *declaration* methods that still take `&Registry` — `prerequisites`, `deconstructors`, `value_struct_decons`, `sum_decons`, `extra_required_types` — read only `flat()` and `all_source_modules()`, never a converter; * `unfold.rs` and `expand.rs`, which compute every decomposition plan, make zero reads of `input_entry` / `output_entry` / `type_table`. The twelve getters move to inherent `pub(crate)` methods on each adapter — they are the adapter's own business now, gathered into one value at one point rather than pulled twelve callbacks deep. Behaviour is untouched by construction: same data, opposite direction. Generation byte-identical, 590 + 518 tests, covertest 48 sections, MSRV clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Say what the registry is for Its module doc was a list of fields — *"Registry holds: item maps, guards, type tables, sidecars"* — and a stale one: the item maps were deleted in #243. Nowhere did it state a purpose. That is why the API drifted into names like `require` and `plans`, which cannot be read without already knowing the answer: require *what*, plan *what*? Replaced with what it is: > **Which type conversions a binding needs, and whether it has them all.** and the four things that make that concrete: **The boundary is the wire.** A binding puts a wrapper on each side — generated Rust the destination language can call, and destination code shaped to match. The wrapper's *body* speaks source Rust, its *signature* speaks wire (`jlong`, `*const T`). The translation between them is a conversion. There is a diagram, because the three-way relationship is the thing everything else hangs off. **A conversion is a chain, not a function** — `destination`, a wire-facing `function`, and `pre_stages`. That is *how* composition works: `Option<Handle>`'s chain embeds `Handle`'s. And a composite need not cross whole: `Option<T>` may be a `T` with a niche, a `(bool, T)` pair, or leaves delivered separately — the adapter's choice, which the registry records so both sides can be written to match. **Conversions are directional.** Two tables, not one. `&str` inbound decodes a `jstring`, outbound allocates one, and one direction may be convertible while the other is not. A callback flips it — `impl Fn(Sample)` is an input whose argument crosses outbound. **It derives the set, then checks completeness — and never writes a conversion.** A binding names a surface; far more types must convert than were named, and computing that closure is the work. Completeness is a meaningful check precisely because the set is derived here rather than handed over. But only the adapter knows what a `jlong` is, so the registry asks for each and fails naming what could not be supplied. Plus an in/out table: model, crossings, decompositions, conversion builder → a conversion per type in the closure, or a failure naming the gaps. Docs only. 590 tests, generation byte-identical, MSRV clippy clean, doc-link warnings unchanged at 17 (measured against this branch's parent, not a different one). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Say how a registry is used, not just what it is for #250 stated the purpose. This states the shape that follows from it: configure it, hand over the answers, read it — and nothing in core calls back into the generator. A `next_request`/`supply` pull loop is not an alternative to a callback trait; it is the same protocol with the arrow flipped. What removes the protocol entirely is the sort: `immediate_edges` is structural, so the demand can be handed over inner-first as a plain list, and a generator building `Option<Handle>` already holds `Handle`. Each crossing is then offered exactly once, which is also what makes a generator's `None` mean `cannot` rather than `not yet`. Records the two consequences worth knowing up front: a `None` is not a failure (reachability from the exports decides), and a self-referential type has no order, so `crossings` breaks the cycle at its entry. Docs only. 590 tests, regen-check byte-identical, MSRV clippy clean, doc warnings unchanged at 49 measured against this branch's parent. * Move the skip report out of the registry First code step of #251. The registry answers "which conversions does this binding need, and does it have them all"; which items a binding skipped bears on neither. Four inputs were read only to print `cargo:warning=` lines — `ignored_functions`, `ignored_types`, `ignored_consts` and `ignored_name_predicates` — so they leave with the five println! loops, into `core::diagnostics`, and a generator calls `warn_unclaimed` itself. Two things fall out. `Declarations::check()` and its two ScanError variants existed only to reject "declared AND ignored", which is now unrepresentable. And the report is built as lines and then printed, so it is asserted on directly instead of scraped off stdout — seven tests that could not exist before, one of which caught a dropped ignore-suppression while writing it. `consts: Option<HashSet>` STAYS: the plan called it a warning switch, but `write.rs` uses `None` to mean "re-emit every const verbatim", which is what cbindgen relies on. Its doc now says which half needs the sentinel. Interim: generators call this from `validate`, the earliest hook they own that sees the model, running exactly where the registry printed before. It moves to `generate` in phase E. 593 tests (590 - 4 + 7), warning output byte-identical on covertest-kotlin and example-cbindgen, regen-check clean, MSRV clippy clean, doc warnings unchanged at 49. -250 lines. * One way to build a registry: Registry::new(flat) `RegistryBuilder` was a verbatim duplicate of `FlatBuilder` — the same source/source_named/items/build, differing only in appending `from_items` — and the registry's own doc admitted it ("the same shape Flat reads prebindgen data with"). Reading captured output is Flat's job, so a build script now says where items come from once, at the layer that owns the question: let flat = Flat::builder().source(FLAT_OUT_DIR).build()?; Registry::new(flat)?.resolve(adapter)?.write_rust(out)?; `Registry::{empty, from_items, builder}` and `RegistryBuilder` are gone; `from_flat` becomes `new`, no longer disambiguating against a sibling. The NotExpressible check stays here: an item the flat language cannot express is a hard error whatever a binding declares. One extra named type per build.rs against 54 lines of duplicate builder and two redundant constructors. Test fixtures get `test_util::reg_from_items` rather than repeating the two steps in ~40 places. 593 tests, warning output byte-identical, regen-check clean (the two untracked example_flat_aarch64_unstable.* files reproduce identically on the parent after the same clean — that is #219, not this), MSRV clippy clean, doc warnings unchanged at 49. * Correct the input: elements alone cannot name every crossing The shape landed in 2428db4 said the configure step is `export` + `decompose`, on the reasoning that types are reachable by walking a declared element's signature. Measured, and it is half true. Dropping the declaration-as-root for declared types leaves regen-check byte-identical — so for every type with a captured body, deriving per usage really is enough, and it is the more correct rule (an output-only type stops being demanded as an input). It fails for a type with NO captured item behind it: `ptr_class!(zenoh::KeyExpr<'static>)` on a re-exported foreign type appears in no signature this model can walk. Nothing derives it, so the declaration is the only statement that it crosses at all — two tests pinned exactly that and caught the claim. So the input needs `cross(type)` beside `export(name)`: the narrow escape hatch for the no-element case, not the common path. Still four inputs against Declarations' twelve setters. Docs only. 593 tests, doc warnings unchanged at 49. * Push declarations in; the registry stops asking Step 1 of three. `resolve` used to CALL the generator to find out what to build — `declarations()`, `local_functions()`, `extra_required_types()`, three of the twenty Prebindgen hooks. That is the callback the module doc forbids, so it is inverted: the generator states its binding, and the registry records. jni.declare_into(&mut registry)?; // generator pushes jni.resolve(registry)? // pairs the two; registry never asks `Registry` gains export / export_const / export_type / cross / reference / local_function, and `Declarations` plus its twelve setters are deleted. The generator drives `resolve` because it is what knows both halves — which is also the shape `generate(..)` takes when emission moves there (phase E). `cross` is directional. Three of the old inputs were one-sided (`required_output_types` output-only, `extra_required_types` per-direction) and one was implicitly both; stating direction at the point of declaration is what stops an output-only crossing from silently lacking its input twin. accessor / method_receiver / crosses_only_in_pieces ride along with a comment: they are properties of a decomposition, and move onto it in step 2. 593 tests, warning output byte-identical, regen-check byte-identical, MSRV clippy clean, doc warnings unchanged at 49. * Five decomposition callbacks become one stated value Step 2 of three. `expansions`, `deconstructors`, `value_struct_decons`, `sum_decons` and `leaf_vec_fold_elements` were five separate calls the registry made back into the generator from inside `resolve`. All five are implemented by one adapter, none by the other, and — measured while planning this — not one of them ever reads more than `registry.flat()`. So they are stated up front instead of asked for: registry.decompose(Decompositions { .. }); `boundary_only_types` moves onto it as `replaces`, where the fact comes from: a type crosses only in pieces BECAUSE something decomposes it, so listing it separately was two statements of one thing. The five fields are still the five declaration families. Unifying the plan IRs behind them is #223, and collapsing them here would only move that seam while pretending it was closed — what this settles is when they are stated and by whom. Prebindgen is down from 20 hooks to 12; the 8 gone are every "what should I build" question. The 12 left are emission (phase E) plus the three conversion hooks step 3 replaces. 593 tests, warning output byte-identical, regen-check byte-identical, MSRV clippy clean, doc warnings 49 -> 36 (deleted hooks took their links). * Hand over the demand; delete the fixed-point loop Step 3 of three, and the one the other two were clearing the way for. `on_input_type` / `on_output_type` / `dispatch_fn_input` were the last questions core asked the generator, and the fixed-point loop existed only because the order those were asked in was arbitrary. Both are gone: let order = registry.crossings(); // sorted, inner types first for c in &order { ... } // the generator's own loop registry.supply(built)?; // graded once `crossings` sorts by `immediate_edges`, which is structural, so no generator is consulted to derive it. Each crossing is then offered exactly once, and `None` means CANNOT rather than NOT YET — the ambiguity #249 named as the cause of converters reading a half-built registry. Two dependencies the structure cannot show, both found by tests rather than by reasoning: * a callback argument delivered as plan leaves needs those leaves' conversions, and a leaf is named by a plan, not by the argument's syntax. Derived in `plan_edges`. * a `convert!` chains through a helper's parameter type, which nothing about the target type mentions. The generator states it: `depends`. The old loop papered over both by retrying. Making the order explicit is what turns them from invisible into stated. `Conversions` is the seam: `Building` is the partial view a generator builds against, `Registry` the total one everything else reads, and a helper serving both takes `&impl Conversions<M>`. 39 signatures moved. Cycles have no topological order, so `crossings` breaks one at its entry — the single case where "every inner first" is not literally true. No example has a recursive type, so this adds a test instead of trusting byte-identity. Prebindgen: 20 hooks -> 9, all emission. 594 tests, warning output byte-identical, regen-check byte-identical, MSRV clippy clean. * Take expansion_plans back off the Conversions trait Self-review of ec0e1df. The plan accessors went on the trait to stop the generic substitution spreading into emission code, and I flagged the result as looser than conversion-building needs. Measuring which callers are actually generic: unfold_plans / error_plans / decon_plans / callback_arg_plan(s) 6 generic callers — the callback + iface_spec path, reachable while a conversion is being built. These have to be on the trait. expansion_plans 0 generic callers. All five sites (fn_plan, render, report, overloads, wrapper) hold a concrete &Registry and always will: parameter folds are read at emission, never while converting. So it comes off, and those five read the field directly again. One less thing `Building` shows a generator than it has any use for. 594 tests, warning output byte-identical, regen-check byte-identical, MSRV clippy clean. * Close the registry's fields; split it into a module Two changes, both about what the registry shows. **Fields are crate-internal.** `input_types` / `output_types` and the five plan maps were `pub`. Outside the crate a table is now reached through `Conversions::conversion` and `crossings` — which is what makes direction a parameter rather than half of a field name, and what stops anyone observing a cell before `supply` has graded it. `expansion_plans` gets an inherent accessor (it is emission-only, so it stays off the `Conversions` trait, per 9fdbb9e); the other maps already had one. `pub(crate)` rather than private because `expand` and `unfold` fill them — they are core's own state, just not the world's. **One 1989-line file becomes eleven, none over 420.** Grouped by what they answer, not by type: mod Registry, Declared, Decompositions, wiring key/cell TypeKey; TypeSubject/TypeCell/TypeEntry/Direction declare configure — every method records, none derives model questions about the model scan derive the crossing set order hand the demand over, grade the answers run prepare/finish/apply plans view Conversions + Building error what can go wrong walk structural type-graph helpers Inherent impls span modules, so `impl<M> Registry<M>` splits with them. Two things the split surfaced rather than caused: * `TypeKeyParseError` and `DuplicateNameError` are reachable from the public API — `TypeKey::parse` returns one, `ScanError::DuplicateName` carries the other — and were never re-exported. Now they are, along with `NotExpressibleEntry`. * the boundary ledger moves 11 classification sites from `registry.rs` to `scan.rs` (2) + `walk.rs` (9). Total unchanged at 167: relocation, no new classifier. 594 tests, warning output byte-identical, regen-check clean, MSRV clippy clean, doc warnings 44 -> 43. * Retire the docs that describe deleted machinery Closing the fields turned a stale doc into a broken link, which is how I noticed the prose had not kept up with three commits of deletion. Swept core and lib.rs for every reference to something that no longer exists: * `Registry::input_types` / `output_types` — now crate-internal, so the module doc pointed readers at fields they cannot reach. It explains `Direction` and `Crossing` instead, which is the answer to the question that paragraph was actually asking. * `on_input_type` / `on_output_type` / `dispatch_fn_input` — the `Prebindgen` module doc still opened by describing them as the trait's main job. The trait has one job left: per-item emission. It says so, and says where conversion went. * `core`'s "phase-oriented pipeline" list and lib.rs's "# Flow" both still walked through `Registry::resolve` and the fixed-point resolver. Both now describe crossings/supply, and lib.rs no longer advertises `on_input_type_rank_0..3`, which has not existed for far longer than this branch. * one comment in `resolve.rs` explaining an ordering constraint in terms of the loop that enforced it. Only deliberate mentions survive — the `Prebindgen` doc naming what is gone so a reader coming from an older version knows where it went. Docs only. 594 tests, MSRV clippy clean, doc warnings 43 -> 42. * Retire TypeCell and TypeSubject from the public API Auditing what a third-party generator can actually reach — the point of this whole branch — turned up five exports no generator uses. Three are right: `TypeKeyParseError`, `DuplicateNameError` and `NotExpressibleEntry` are unreachable by accident but nameable on purpose, since `TypeKey::parse` returns one and `ScanError` carries the others. `TypeCell` and `TypeSubject` are not. Closing the type tables in 6af68bb left nothing public that returns or accepts either — they became API a caller could name and never obtain. `pub(crate)`. Which then showed what was only alive because it was public: * `TypeSubject::syntax` — read by nothing at all. * `TypeSubject::kind` — read only by tests, pinning that a source reading survives into a cell. Kept, `#[cfg(test)]`, so the lib build stops pretending it has a caller. * `TypeSubject::Adapter(syn::Type)` — the payload was never read back, only matched as `Adapter(_)`. Now a unit variant, and `test_util::cell` loses the key argument it only had to build one. None of this was reachable before the fields closed, which is why it sat here: dead code inside a public type looks alive. 594 tests, warning output byte-identical, regen-check clean (no tracked drift), MSRV clippy clean, doc warnings 42 -> 41. * Box TypeSubject::Source — stable clippy, not MSRV Making `Adapter` a unit variant in 7d0f985 left `Source(TypeRef)` as the only variant carrying anything, and `large_enum_variant` compares the largest against the SECOND largest: a 264-byte enum whose runner-up is empty. Boxing takes it to 8, which is the right shape anyway — cells are numerous and most are `Adapter`. The miss is in how I checked, not what I changed: CI's clippy runs on a `[1.85.0, stable]` matrix, and this lint fires only on stable's 1.97 clippy. I verified MSRV and stopped, so a green local run said nothing about the job that failed. * Split building a registry from reading one `Registry` was both: `&mut self` declaring methods and read-only accessors on one type, so "still being described" and "finished, and answerable" were a phase you had to be careful about rather than something the types knew. Now `RegistryBuilder` owns everything mutating and `build()` is the only way to get a `Registry`. Nothing can add a crossing to one, which makes "every crossing has a conversion" a fact about the type. let registry = Registry::builder(flat)? .export(&name) .decompose(decompositions) .convert_with(|crossing, built| gen.convert_crossing(crossing, built))? .build()?; Declarations consume `self`, so they chain. Two ways to hand conversions over, per your request: * `convert_with(f)` — chainable; `f` is called per crossing in dependency order with everything already built. This is a callback, and it is not the thing we removed: the registry does not re-enter generator logic on its own schedule, the walk is finished before the method returns, and the closure is the caller's. It is `crossings` + a `for` loop, written once instead of in every generator. * `crossings()` + `conversions(map)` — for filling the holes yourself. `conversions` accumulates, so the two compose. `prepare`/`supply` are gone; `validate` now takes the `Building` view instead of a whole `Registry`, which is all it ever read. `scanned()` is `#[cfg(test)]`: it is the state between described and answerable, which is exactly what this split exists to keep out of everyone else's hands. 594 tests, warning output byte-identical, regen-check clean (no tracked drift), BOTH clippy toolchains clean, doc warnings 41 -> 34. * Delete Registry::scan_declared — the split was still leaking Self-review of 21c403c: I claimed a strict builder/read-only split, then checked. `Registry` still had one public `&mut self` method, so the claim was not yet true — a caller could scan a finished registry. Zero callers: `RegistryBuilder::derive` subsumed it the moment the builder landed. Its doc was stale too, still describing `adapter.ignored_functions()` and the skip warnings that left for `core::diagnostics` several commits ago. Now `Registry` has NO public mutating method, and the only `&mut self` left is `RegistryBuilder::crossings`, which caches the derivation. 594 tests, warning output byte-identical, regen-check clean, both clippy toolchains clean, doc warnings unchanged at 34. * Update the docs the builder split invalidated Checked the module doc against the API it describes and found its worked example wrong in four ways: `export` does not return `Result`, `cross` takes a direction, `supply` no longer exists, and the whole thing still used `Registry::new`. A worked example that does not compile is worse than none — it is the first thing a generator author copies. Rewrote it around the two types, since that IS the change: a builder is still being described, a registry is finished and answerable. Added the `crossings`/`conversions` alternative, and said plainly why `convert_with` is not the callback we removed — the walk finishes before it returns, the closure is the caller's, and the builder chooses nothing about when it runs. Swept the rest: `Registry::new` in lib.rs's doctest and four module docs, `Registry::prepare` in the core pipeline description, and two references to `Registry::scan_declared` — deleted in b708c7e — in write.rs and kotlin_emit.rs. Docs only. 594 tests, warning output byte-identical, regen-check clean, both clippy toolchains clean, doc warnings 34 -> 33. * Rename the generators to what they are: builders Mechanical, and alone in its commit so the next one is reviewable. Today's `JniGen` and `Cbindgen` are pure declaration holders — everything on them either records what to emit or answers a question about it — so they are `JniGenBuilder` and `CbindgenBuilder`. That frees the short names for the built objects the next commit introduces, matching the convention already here: `Flat::builder()`/`FlatBuilder`, `Registry::builder()`/`RegistryBuilder`. Renamed OUTSIDE string literals only. Three of those strings matter: `"// Auto-generated by JniGen — do not edit by hand."` is written into every generated file, and two `"JniGen::on_function …"` diagnostics are user-facing. Renaming inside them would have moved the goldens and made this commit unreviewable — the header appears in committed output. Prose and doc links follow the code for now; the next commit revisits them, since it is the one that makes `JniGen` mean something again. 594 tests pass untouched, regen-check byte-identical (no tracked drift), warning output byte-identical, both clippy toolchains clean, doc warnings unchanged at 33. * The generator owns the model and the registry A build script had to know three types and a four-step dance to say "generate bindings from this directory": let flat = Flat::builder().source(DIR).build()?; let registry = Registry::builder(flat)?; let gen = jni.resolve(registry)?; gen.write_rust(&rs)?; `Flat` and `Registry` are pipeline internals. Now: let jni = JniGen::builder() .package(..).fun(..) .source(DIR) .build()?; jni.write_rust(&rs)?; jni.write_kotlin(&kt)?; `JniGenBuilder`/`CbindgenBuilder` gain `source` / `source_named` / `items` — the same three feeders `FlatBuilder` has, because they ARE that feeder: the builder holds a `FlatBuilder` and `build()` runs the pipeline the caller used to run by hand. `JniGen` and `Cbindgen` are the built objects, each holding its registry as a field, each publishing its own writers. `Generation<E>` is deleted, and that is the point rather than a side effect: core no longer owns the artifact-bearing type, so a generator decides what its artifacts are and what they are called. `core::write::write_rust` stays a free function both call. `Registry::finish` goes with it — the post-resolve invariant check is now the generator running its own `validate_resolved`, which is the one place that knows what an invariant means here. `build_with(registry)` is the crate-internal seam tests use to feed synthetic items without a directory; `build()` is that over `source`. 594 tests, warning output byte-identical, regen-check byte-identical with no tracked drift, both clippy toolchains clean, doc warnings unchanged at 33. * Name the example variables after what they now hold `let gen = jni.build()` read backwards once `JniGen` became the built type: the thing called `jni` was the builder, and the thing called `gen` was the JniGen. Now `binding` builds and `jni` is what you write from. The examples are how this API is read before it is used, so the names being the wrong way round is worth a commit of its own. Output byte-identical, warnings byte-identical, regen-check clean. * Delete Registry::supply — the read-only claim was false Review catch (#249 review of the combined head). `21c403c` said `supply` was gone and `b708c7e` said no public `&mut self` remained on `Registry`. Both were wrong, and `supply` shipped: a caller could build a complete registry and then replace any conversion in it, with only core's completeness rerun and the generator's `validate_resolved` skipped entirely. Exactly the half-filled mutable protocol this stack claims to have removed. It had no callers — handoff is `RegistryBuilder::{convert_with, conversions, build}` — so it is deleted outright. **Why it survived two commits that checked for it.** I verified with `grep "pub fn .*&mut self"`, which needs both on one line; `supply`'s signature spans four. The check could not see the thing it was for. So the replacement is a test that strips ALL whitespace before matching, making a multi-line signature indistinguishable from a one-line one. I confirmed it fails by reintroducing a `pub fn __regression_probe(&mut self)` — the first version of the test passed with that present (the whitespace collapse left a space after `(`), which is the only reason I found out it was useless. `Registry::crossings` goes `pub(crate)` with it: same residue, no external caller, and the read phase in the module docs never listed it. Swept the docs the review enumerated — `lib.rs`, `core/mod.rs`, `resolve.rs`, `order.rs`, `registry/mod.rs`, `write.rs`, `kotlin_emit.rs`, `declare.rs` — all still describing `Registry::supply`, `Registry::finish`, or linking declaration methods that now live on `RegistryBuilder`. 595 tests (594 + the guard), warning output byte-identical, regen-check clean with no tracked drift, both clippy toolchains clean, doc warnings 33 -> 32. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#254) Two things the map got wrong since #247 synced it. The ledger is 167, not 202. #248 deleted the pattern engine and took 35 sites with it — types_util 40 to 14, jnigen/builder 13 to 4 — so L2 is in progress, not "not started", and the claim that the ledger has not begun falling is false. Recorded with the distinction that matters: those 35 went away because their code went away, which is deletion rather than migration, and the 45 that remain in api/core are the ones that have to start reading elements. The #249-#253 stack is merged but not on this branch. It landed PR-into-PR onto flat-drop-pattern-engine, of which only #248's commit ever reached language-integration, leaving 28 commits — the registry and generator API redesign tracked by #251 — invisible to the map. Recorded as L1.75 for the same reason L1.5 is recorded: the map should show where the program went.
…#255) #254 recorded the #249-#253 stack as merged elsewhere and pending a re-merge. It is not pending: #248 squash-merged flat-drop-pattern-engine AFTER #249 landed the stack into it, so d845c8f carries the registry and generator redesign under a title naming only the pattern engine. flat-drop-pattern-engine still reports 28 commits ahead because a squash records no ancestry. The trees differ by nothing, which is the check that should have been run: declare.rs, run.rs, view.rs and order.rs are present, convert_with is present, Registry::supply is gone, and both adapters expose builder(). Says so in the section, since the same misreading is available to anyone who opens the log.
registry/walk.rs is deleted. immediate_edges takes its structural children from TypeKind instead of taking a syn::Type apart, and spells each edge from the child's own origin.syntax -- classify off kind, spell off syntax. Three of the deleted arms were dead rather than migrated: lower_type refuses non-unit tuples and raw pointers, and Group/Paren are transparent in the model, so nothing the frontend accepts could reach them. The Type::Path generic-args arm is dead for a reason already written down in named() -- generic arguments are lowered but not retained, because no declaration takes type parameters. A composed type is ADMITTED to the model, not classified on the fly. The walk needs a reading for every type it is handed, and expansion composes spellings the source never wrote -- an Option<T> around a T it found. My first attempt gave Flat a query that lowered on an index miss and answered without recording anything. That was wrong, and the tree already says so: add_local_function lowers a binding-local sig!(..) through the same grammar and ADMITS it, because otherwise the "one index" #243 established is a lie the moment a binding composes something. So Flat::admit_type is that function's peer, and ensure_entry -- the one place a cell is born, and therefore the one place a type enters the pipeline -- is where it is called. immediate_edges goes back to a plain index read, because by the time the walk reaches a type the cell for it already exists. Every later lookup gets the same answer from the same place. Measured after the change: ZERO types are refused by the grammar, across every in-tree example and all 523 tests. TypeSubject::Adapter is therefore unreachable, which is L2e's precondition -- left in place, with the evidence, for the PR that deletes it. Two more changes the walk forced, both improvements: The field lookup takes the type's NAME from TypeKind::Named rather than from bare_path_ident on the spelling. That is what makes a transparent wrapper work: Box<Node> classifies as Named { Node }, so it reaches Node's fields, where asking the syntax for a bare ident answered None and dead-ended. A declared type the source never mentions is now classified-but-placeless rather than unreadable. Foreign is a name, and the grammar can say that much about any spelling that parses; what is genuinely absent is a file and line. That is the reading-vs-position distinction L1.5 drew, applied to the case that shows why it matters. No production behaviour moves -- location() was already None for it, and kind() is test-only. registry/scan.rs keeps its two sites, with the reason in the code: they inspect a key a BUILD SCRIPT AUTHOR wrote, to diagnose that spelling. No source type is being classified, so there is no element to read instead. That is the map's "legitimately the adapter's business" case, and the first entry to actually land in it -- so L2a is 9 sites, not the 11 planned. Ledger 167 -> 158. Reported: regen-check drifts ONE file -- perftest-kotlin loses 50 lines, nothing added. They are JString_to_String_c7f3ca43 and its output twin, and they were provably dead: the committed file mentions that hash exactly twice, both definitions, zero call sites. Box<String> IS String in the model, so the old syntactic walk registered a plain-String cell that nothing ever used. Explained: dead generated code stops being generated; no live converter, signature or Kotlin file moved. Asserted: every structural edge in the scan now comes from a classification, and every type in the table has one. cbindgen::type_contains_vec goes with it -- its one call site already held the TypeRef and was digging the syntax back out. TypeKind::Sequence is the whole question, since Cow<'_, [T]> lowers to it just as Vec<T> does, so the two spellings it tested separately are one classification. is_vec and cow_slice_elem stay; they have other callers.
* flat: seal TypeRef — only the model may mint one
`TypeRef` was `pub struct { pub kind, pub origin }`, re-exported at
`prebindgen::core::flat`, with four public composers from #278. Anyone could
assemble one, and nothing checked that `kind` agreed with `origin.syntax`, so
holding a `TypeRef` proved nothing about where it came from.
The invariant it now carries:
Every TypeRef was classified by the model. Flat classified it from source
syntax, or the registry composed it by layering over something already
classified. Nothing above the model can mint one.
Fields become `pub(super)`, composers `pub(crate)`, and reads go through
`kind()` / `syntax()` / `location()`. There is no cheaper version: a public
field IS a constructor, so restricting only the composers would block nothing.
The invariant is unconditional — no phase, no lifetime, no direction — which is
what makes it hold for a STORED value. That was the requirement: a `TypeRef`
lives in `UnfoldLeaf::out_ty` and `FoldLeaf::ty`, inside plans the registry
itself stores, so any borrow-carrying token would make the registry
self-referential.
It deliberately does NOT claim the converters exist. That is false by design for
stored readings: `unrequire_output` exists precisely to leave a cell whose
converter cannot resolve (a `Vec<opaque-handle>` delivered element-by-element —
"a jlong wire is not JObject-shaped"), and a `SumTag` leaf never has one. So
converter existence stays a lookup answering `Option`, and the relation is 0..2,
not 1-to-1.
Two compile-fail doctests are the acceptance test, each verified to fail on
privacy specifically: E0451 for the struct literal, E0624 for the composer.
Mechanical otherwise: 172 read sites migrated by walking rustc's own E0616
spans rather than by pattern-matching text, so no site was missed and none was
guessed. Two `&`-artifacts of that rename would have compiled while cloning a
reference instead of a value (`suspicious_double_ref_op`); clippy caught both.
Also fixes 6 doc links this would have broken, and 2 that were already broken.
Verified: 550 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`cargo clean -p`, covertest-kotlin 48/48, rustdoc no new errors (37 -> 35).
* jnigen/core: drop the remaining double borrows the rename left
Review on #280 found 12 sites where `&x.y.syntax()` / `&x.y.kind()` produces a
`&&syn::Type` / `&&TypeKind` that only compiles through deref coercion and match
ergonomics.
My own cleanup missed them: the sweep matched a SINGLE identifier before the
accessor, so `&reading.syntax()` was fixed while `&field.ty.syntax()` and
`&c.subject.kind()` were not. Same blind-spot shape as #271's census — a pattern
written for one spelling of the thing it was looking for.
Measured, because I had cited clippy as the guard here: clippy does NOT catch
this class. Reintroducing one double borrow leaves
`clippy --all-targets --all-features -- --deny warnings` at exit 0 with zero
warnings. The "clippy clean" line in #280 was true and was not evidence for
this.
The two `&element.syntax()` in `flat/tests/roundtrip.rs` are deliberately kept:
`Element::syntax()` returns an OWNED `syn::Item`, so the borrow is real. That is
why this was not a blind regex sweep.
Verified: 550 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.
* flat: enforce the invariant at api::core, not just at the crate edge
P1 review on #280 is correct: the doc claimed "nothing above the model can mint
one" while every composer and `Flat::classify` were `pub(crate)`, so any
in-crate adapter could still mint — and the tree already did, at
`jnigen/emit/sum_out.rs:71`. The compile-fail tests proved only the
out-of-crate boundary. The claim was false at the commit that made it.
Enforced rather than softened. Four visibilities now draw the boundary at
`api::core`:
borrowed / optional / scalar pub(in crate::api::core)
named pub(super) -- flat alone
Flat::classify pub(in crate::api::core)
the kind / origin fields pub(super) -- unchanged
The one in-crate mint is gone rather than documented: the `SumTag` selector
needs a type the model already declares, so the DECLARATION now answers —
`flat::Variant::type_ref()` — instead of an emitter composing a reading from an
ident. That is also the better model: a consumer holding the element no longer
has to mint a reading and hope it matches what the model would have said.
Measured, not asserted: an `api::lang` adapter naming all four routes now fails
with four `E0624`s. The doctests are relabelled to say what they actually prove
(the crate edge, E0451 + E0624) and to point at the visibility table for the
stronger claim, which no doctest can reach inside the crate to test.
This does NOT resolve #281 — composition still lives in `expand`/`unfold`
rather than behind a registry API, and a composed reading is still discarded and
re-derived by `ensure_entry`. It removes the adapter-side hole only.
Verified: 550 lib tests, 21 doctests, `--all --all-features` 14/14 suites,
clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical
after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48,
rustdoc 37 -> 35 errors (no new).
* flat: Variant carries its own reading instead of composing one
Second P1 on #280 is correct, and it is a hole my own fix for the FIRST P1
opened. `Variant::type_ref()` composed `TypeRef::named(&self.name)`, and
`Variant` has public fields with a public `Origin::new`, so a consumer could
assemble a `Variant` named `String` and get `Named` over the spelling `String`
— which the model reads as `Str`. Exactly the kind/syntax disagreement this PR
seals, reachable from OUTSIDE the crate, and invisible to both compile-fail
doctests because assembling the element is not minting the type.
Reproduced before fixing, out-of-crate against the built rlib:
kind = Named { id: TypeId { name: "String" } }
syntax = String
The parser now takes the reading and `Variant` stores it; `type_ref()` returns
it. STORING is what closes it: whatever a caller does with the other fields, the
reading is the one the model made, and no caller can mint a different one to put
in its place. The field is `pub(super)` as a second line — a `Variant` cannot be
assembled outside `flat` at all, so `name` and `reading` cannot be paired
inconsistently with each other either.
Both halves verified: the out-of-crate forge now fails to compile, and an
`api::lang` attempt fails `E0451`.
The new compile-fail doctest is documented for exactly what it pins — "a
consumer cannot assemble a `Variant`" — and no more. Measured: it still passes
with the field made `pub`, as `E0063` rather than `E0451`, because a consumer
cannot produce a `TypeRef` to supply either way. The visibility is the check
that discriminates, and the compiler runs it every build.
Verified: 550 lib tests, 22 doctests, `--all --all-features` 14/14 suites,
clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical
after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48,
rustdoc 37 -> 35 errors (no new).
) * core: the registration path carries readings instead of re-deriving them Closes #281. A composed reading was built, thrown away, and independently re-derived: `expand.rs` composes `pty.optional()`, `unfold.rs` handed only its SPELLING to `require_output`, and `ensure_entry` classified those tokens from scratch and stored its own twin. Two classifications of one type, by two paths that never met, and nothing compared them. The issue proposed moving the composers behind a registry API. That would not have closed it, and the PR says so on the issue rather than silently skipping it: the loss is at the door, not at the composer, and it happened again at every recursion step — `immediate_edges` had each child as a `&TypeRef` and did `child.syntax().clone()` so the next level could re-classify it. One rule now: a type enters the registry as a READING; only a spelling nobody has classified yet goes through `classify`. ensure_entry(dir, &TypeRef, root) stores the caller's reading, INFALLIBLE register_type_{recursive,inner} take &TypeRef, infallible require_*/unrequire_* take &TypeRef immediate_edges returns (Direction, TypeRef) intern / intern_recursive the one fallible door, for a spelling Infallibility falls out rather than being claimed: `ensure_entry` was fallible for exactly one reason — `classify` refusing a spelling — and a reading has already been through that. #281 planned to assert layering is total and pin it with a test; carrying the reading makes the question not arise. Ten of the twelve `require_*` sites already held a `TypeRef` and called `.syntax()` on it at the door, so those are deletions. `unfold.rs`'s composed `cv_ty` now uses `optional()` instead of `parse_quote!(Option<#leaf_ty>)`, pairing kind with spelling in one place. `Flat::classify` is down to ONE production caller, `intern`. Acceptance test, verified to fail with the fix reverted: `a_composed_reading_reaches_the_cell_unchanged` composes `Option<Thing>` — a spelling the source never writes — and asserts the cell keeps the source location. Reverted, the cell holds a PLACELESS reading, which is what a diagnostic about that crossing would have printed. Two mistakes caught in progress rather than shipped: `intern` does not recurse, so six sites that were `register_type_recursive` needed `intern_recursive` (caught by cbindgen's example panicking, not by a test); and the regex that dropped `.syntax()` added `&` to values that were already references (`needless_borrow`). regen-check byte-identical — which here is EVIDENCE, not a regression check: the cell used to hold `classify(spelling)` and now holds the caller's reading, so identical output is the first confirmation that the two answers agree for every type the examples exercise. Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical after `git clean -fd examples/` + `cargo clean -p` (forced rebuild confirmed by the Compiling/Generated lines), covertest-kotlin 48/48, boundary ledger unchanged at 127. * core: narrow `intern` to `api::core`, matching what #280 sealed Review on #283 is correct. I widened `intern` to `pub(crate)` so two test modules could reach it, and never checked what else that admitted: classifying a spelling MINTS a reading, so a `pub(crate)` door let `api::lang` hand the registry tokens of its own and receive a `TypeRef` back — exactly the capability #280 closed by making the composers and `Flat::classify` `pub(in crate::api::core)`. A one-door design is only worth having if the door is no wider than the entrances it replaces. Both test callers are under `api::core` (`resolve/tests.rs`, `unfold/tests.rs`), and every production caller is in `core::registry`, so the narrowing costs nothing. Measured rather than assumed: an `api::lang` call to `intern` now fails `E0624`. The doc records why the visibility is what it is, so a future widening has to argue against the reason rather than rediscover it. Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48. * core: intern reuses a known reading; unrequire and Layered stop downgrading Two review points on #283, both correct. [P2] `intern` classified unconditionally and only then called `ensure_entry`, whose existing-cell arm discards the reading. So a repeated registration of the same key still derived a second reading that never met the first — the very shape this PR removes, surviving as redundant work rather than as a replaced cell, and contradicting the stated rule that only a spelling nobody has classified yet goes through `classify`. `intern` now looks the key up first, in EITHER direction (a reading is direction-free), clones the authoritative answer, marks or creates the directional cell, and calls `Flat::classify` only on a genuine miss. That also restores the old `ensure_entry` property of classifying only when a cell is new. [Copilot] `unrequire_*` still took `&syn::Type` while the PR description claimed the whole registration surface was reading-based. The description was the thing that was wrong, so the code is now what it claimed: unrequire_output(&TypeRef) pairs with require_output clear_root(dir, &TypeKey) the keyed primitive underneath Keyed is the honest signature for `clear_root`: un-requiring creates no cell and classifies nothing, so it is the one registration-adjacent operation with no reading to carry. `run.rs` already held keys and now passes them straight in, dropping a `to_type()` round trip. Two consequences, both taken rather than worked around: * `Layered::layer_types` was `Vec<syn::Type>`, built by mapping `.syntax()` over `TypeRef::layer_types()` — the same discard one layer down. It now carries readings, which is what let the `unrequire_output` call site pass one. * `unrequire_input` has no callers once `run.rs` uses `clear_root`, so it is deleted rather than kept as dead code behind a symmetry argument. Two lines to restore if an input-side caller appears. Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48. No `.syntax()` downgrade remains at any registry door.
…285) First of #284's three steps, and it is a PREREQUISITE rather than a win on its own — stated plainly because the numbers say so: .syntax() calls 19 -> 24 (UP: selector 15 -> 15, trait_impl 4 -> 9) boundary ledger 127 -> 127 (unchanged) My own plan predicted the ledger would fall here. It does not, and reading the sites says why: selector's 3 and trait_impl's 11 counted matches are `is_unsized_spelling`, `decoded_vec_satisfies` and the `Type::Reference` bridgeability guards — all genuine SPELLING questions, and the documented exemption. Nothing was owed there. What it does change is the type of the inner parameter. `input_wrapper_shape` / `output_wrapper_shape` and their four sub-handlers took `t1: &syn::Type`, and `selector.rs` produced it by destructuring a reading it already held. Now `t1` is a `&TypeRef`, so a handler cannot be reached with tokens that have no reading, and #284's step 2 has something to pass to `input_entry`/`output_entry` when those take a reading. `produced` deliberately stays a `&syn::Type`, and the slice case is why: at `selector.rs`'s `&[T]` arm the adapter COMPOSES `Vec<#elem>`, and #280 sealed minting to the model — `api::lang` has no `Vec<T>` reading to make. That turns out to be consistent rather than awkward: `produced` is defined as the tokens the converter yields, and every question asked of it (`is_canonical_spelling`, the `Type::Reference` guards) is a spelling question. So the split is meaningful — `produced` = what is emitted, `t1` = what is wrapped. Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical after `git clean -fd examples/` + `cargo clean -p` (rebuild confirmed by the Compiling lines), covertest-kotlin 48/48.
Second of #284's three steps, and the one that pays for the first. reading(&TypeKey) the ONE keyed door conversion/input_entry/output_entry take &TypeRef reading_of(&syn::Type) the visible "I only had tokens" step The guarantee: an entry lookup cannot be called about a type the registry does not know. #280 sealed minting, so a `TypeRef` can only come from the model or from `reading`/`reading_of` — and those answer `None` for an unregistered type, which the caller must now handle. Before, any tokens could be passed and got a silent `None` back. The important find was NOT in the trait. `Registry` carried INHERENT `input_entry`/`output_entry` taking a `&syn::Type` (`scan.rs:579/585`), and an inherent method wins over a trait method on a concrete receiver — so every caller holding a `Registry` used the spelling door and the trait's signature could not close it. Changing the trait alone left 104 sites silently compiling against the old path; closing the inherent pair is what surfaced them. Same "second door inside the room" that hid `classify` behind `Registry::reading` until #267, and it is the reason this PR is larger than the plan predicted. PR #285's payoff lands here: every `t1_ty` in the wrapper-shape handlers became `t1`, because the handler already holds the reading. Honest numbers: to_type() (prod) 45 -> 37 boundary ledger 127 -> 127 (unchanged, and structurally so — it counts syn variant mentions, and a signature change names none) The 37 that remain are spelling needs — C type names, `quote!` targets, diagnostics. NONE feeds a lookup, which is this issue's acceptance test: `grep to_type() | grep -E "input_entry|output_entry|conversion|reading"` is empty. `reading_of` is deliberately not a convenience wrapper for the entry lookups. It returns a reading, so the `None` stays visible at the call site; a spelling-taking `entry_of` would have restored exactly the door being removed. Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.
First piece of #284 step 3. Small on purpose — see the finding below for why the rest is not a helper swap. `struct_plan.rs` already held `reading`, `optional_inner` and `bare_ref` as `TypeRef`s, then downgraded all three to spellings and looked them back up: let effective_ty = reading.syntax().clone(); ... registry.reading_of(&effective_ty).and_then(|tr| registry.output_entry(&tr))? Three of those round trips are gone; the file now has ZERO `reading_of` calls. Two of the five sites were not round trips but latent defects of the #270/#272 family — asking a spelling a question the model answers: * `pat_match_top(&slot_ty, "Vec")` compares the last path segment, so a `Box<Vec<T>>` answered FALSE. Now `slot.sequence_elem().is_some()`. * `bare_path_ident(&slot_ty)` takes the spelling apart to get a name, which answers about the WRAPPER for `Box<T>`. Now the name comes off `TypeKind::Named { id }`. Neither is reachable from the in-tree examples — goldens are byte-identical — so these are the same "correct output, no signal" shape as #266/#273 rather than observed breakage. FINDING that resizes the rest of step 3: `emit/flat_input.rs` holds 20 of the 34 `option_inner_type` callers and 12 `reading_of` sites, and the reason is not the helper — it walks `syn::Fields::Named` directly, while `flat::Struct::fields` already carries a `TypeRef` per field. So that file needs the ELEMENT-WALKING change (take `&flat::Struct`, walk `struct.fields`), which is the same follow-up the umbrella records from #283 for `scan_struct`/`scan_enum` — not a peel substitution. It is its own PR rather than a rushed extension of this one. Ledger unchanged at 127: `types_util` only falls when its callers stop needing it, and `option_inner_type` still has 34. Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.
Continues #284 step 3: the remaining `option_inner_type` / `reading_of` sites, one consumer at a time. `TypeRef` gains three accessors, each answering a question a spelling was being asked before: `callback_args()` (the reading counterpart of the `extract_fn_trait_args` *classifier*), `erased_wrapper()`, and jnigen's `enum_probe()` + `Declarations::is_kotlin_enum_reading()`. Converted: `classify_leaf` (now zero `reading_of` calls and no spelling local at all), `build_flat_input_plan`, `build_option_scalar_input_plan`, `vec_build_elem`/`vec_build_helpers`/`collect_vec_build_elem_types`, `sum_ctor_arg`, four round-tripped entry lookups, and `PlanError`, which now carries `Box<TypeRef>` and names a source position. Two helpers fell out **provably dead** — `impl_into_target` (the model refuses `impl Trait` that is not the callback form, so it was already unreachable before this branch; `cargo check` said so) and `slice_or_vec_elem`. Both are replaced by a comment recording what stood there. `extract_fn_trait_args` is gone from jnigen production code entirely. Fixed along the way: `Box<Priority>` and `Option<Box<Priority>>` now reach their `enum_class!` declaration instead of missing on a `Box < Priority >` key; and `build_output`'s two distinct failures no longer share one message that gave correct advice for one and actively wrong advice for the other. Ledgers move **down**, which is the direction they exist to reward: boundary 127 → 122, spelling census `vec_build.rs` and `kotlin_emit.rs` to zero. ## Review found a real defect, twice The rule "model peels are always better" is **not** unconditional, and I applied it past an exception the codebase already documented (`decoded_vec_satisfies`). The sharper split: * **`kind` decides what the destination sees** — surface type *and wire*. * **`syntax` decides how the value is converted**, and Rust tells apart what the model erases. The specialized input lowerings do not decode their parameter, they **rebuild** it — so selecting them off `kind` alone made a `Box<Option<T>>` parameter receive a bare `Option<T>`: `E0308` in the generated crate. Round two found the same defect one layer out: an erasure sits *outside* the layer it wraps, so `Box<&Vec<T>>` classifies as `Ref` and a guard that reads `kind` first discards the wrapper before looking. Both are fixed by asking the **model** (`erased_wrapper()`, since it is the only thing holding both halves) before each peel, never by re-adding spelling probes: net spelling probes added is zero. Refusing is a **gap, not a requirement** — `Box::new(v)` is what the syntax asks for — so #292 tracks rebuilding instead of refusing, along with the wire-from-`kind` rule (#230's real diagnosis) and the stripped-spelling model facts a rebuild needs. Two regression tests, each on a **control pair** so it cannot pass vacuously, and the ordering one verified to fail when its guard is disabled. Generated Rust is never compiled by this suite (#269), so they pin that the emitter is never *asked* to write the ill-typed code rather than the `E0308` itself; their docs say so. Also fixes a `cargo fmt --check` failure that had already turned CI red.
…293) `TypeKind` erases `Box`/`Cow`, which is right: `Box<Option<T>>` is one optional to every destination language. But conversion follows the SYNTAX, and the two facts a rebuild needs were not on the model. `TypeRef::erased_wrappers()` and `stripped_syntax()`, both derived from the spelling rather than stored — `lower_type` keeps discarding the wrapper, and nothing new can disagree with `syntax`. The stripped spelling is defined by its invariant, not by its loop: it is the spelling whose own lowering yields exactly this `kind`, so the peel runs to a fixed point. `erased_wrapper()` becomes the head of the list. An erasure sits OUTSIDE the layer it wraps, so both answer for one layer's spelling only; the tests pin that with the pair `Box<&Vec<T>>` / `&Box<Vec<T>>`, each invisible to the other's vantage point. The audit found one live miscompilation. Builder delivery binds the returned value and matches it against `Option`'s patterns, which match ergonomics does not see through a `Box` — every other peel site classifies, which is the erasure working. `read_through_erased_wrappers` undoes them at the single point the value enters the delivery. Its fixture is in perftest-flat, whose binding covertest compiles: verified by disabling the fix and watching `E0308`, and round-tripped on the JVM. `Box`'s read op drops its parens — every consumer splices into a `let` initializer, where converters happen to `#[allow(unused_parens)]` and wrapper externs do not. Refs #292 (item 1), #229 (L4/L5).
#292 item 3, and #289 with it — the two are one change because #289 alone breaks the build: reading a field's layer off the model is what makes the emitter rebuild an `Option` for a slot ascribed `Box<Option<_>>`. `build_through_erased_wrappers` is the input dual of #293's reader, on the same `WRAPPER_OPS` rows, applied innermost-out. The three specialized input lowerings descend instead of refusing, collecting each layer's wrappers on the way down — an erasure sits outside the layer it wraps. A layer's wrappers are applied only where that layer exists; applying them unconditionally double-wraps when two layers are the same reading. `Cow` keeps `build: None` as POLICY, not impossibility: `Cow::Owned(v)` is well-typed, but always-Owned pays a copy per call and removes the borrow path the source asked for, observably. Two findings the fixtures forced out: * **A wrapper silently cost a parameter its lowering.** The data-class declaration was keyed by the wrapped spelling, so `Box<Payload>` found no `Payload` declaration and fell to the general converter — no error, no diff. Declarations are keyed by `stripped_key()` now; conversions keep `key()`. * **A wrapper over a terminal had no converter at all.** `input_transparent_bridge` delegates to the stripped spelling and re-wraps, tried last so no existing route changes. Refused with stated reasons: `Box<&T>` (a converter yields an owned value), `&Box<Vec<T>>` (needs a per-call clone), `Vec<Box<T>>` elements (helper-trio name collision — see the follow-up, this one is soft). #289: `build_flat_struct_node` takes `flat::Struct` and peels its fields off the model. Both censuses move DOWN — spelling helpers 18 → 9, ledger 127 → 126 — the first in the #284 chain to do so, because it retires callers rather than re-typing signatures. Review catch, fixed in `eb9df58`: the wrap refactor had hoisted an optional node's field decodes out of its presence gate, so a null object's inert placeholders were decoded — a required handle field's pointer `0` reads as a closed handle and `null` became an error instead of `None`. `Holder` is the fixture that shows it. Every wrap verified by disabling it and reading the error naming its shape. Against the merge base the generated bindings have zero genuinely-removed lines. Closes #289.
#292 item 2 stated the invariant as "same `kind` ⇒ same wire". That is false, and prebindgen violates it on purpose: jnigen crosses `&[Payload]` as a jlong Vec handle and `Vec<Box<Payload>>` as a `JObject`, both surfacing as `List<Payload>`. Choosing a wire is the generator's job, and the wrapper absorbs the difference — a caller cannot tell. What a caller CAN tell, and what the erasure promises will not happen, is the destination-language **type** changing because the source spelled a `Box`. So the rule is: same `kind` ⇒ same destination-language type; the wire is free. It scopes to CONVERTED positions. A `repr_c_struct` is a layout mirror — reinterpreted from the source struct's bytes — so its field types are a layout fact, `Box<T>` really is a different C type from `T`, and the spelling is load-bearing by construction. That is the one place the usual split inverts, and it is why #230's headline example (`Payload.label`) is not a defect. Reusing a mirror's spelling test in a converted position is how the rule breaks. A tagged-union payload is converted, and took its opaque-pointer arm from the `Box` in the spelling: `Option<Box<Handle>>` crossed as `handle_t *` while `Option<Handle>` — the same optional handle to every destination — was REFUSED, falling through to a converter-agreement check its structural output marker (`()`) can never pass. An erased wrapper decided expressibility, which is the same defect shape #292 found on the jnigen side. The arm asks the declaration now, off the model. All three spellings — `Option<Box<Handle>>`, `Option<Handle>`, `Handle` — present `*mut handle_t`, and their converter BODIES differ: the boxed one hands over the box it has, the others are boxed by the converter. The C type follows `kind`; the conversion follows the syntax. Pure addition — previously-refused shapes now resolve, and the regen is byte-identical against the merge base. Refs #292 (item 2), #230, #229.
#292 item 2, with the rule restated — as written it was wrong. "same `kind` ⇒ same wire" is false. The wire is the generator's to choose, and prebindgen varies it deliberately: jnigen crosses `&[Payload]` as a jlong Vec handle and `Vec<Box<Payload>>` as a `JObject`, both surfacing as `List<Payload>`. The wrapper absorbs the difference and a caller cannot tell. What a caller CAN tell, and what the erasure promises will not happen, is the destination-language **type** changing because the source spelled a `Box`: Same `kind` ⇒ same destination-language type. The wire is free. It scopes to CONVERTED positions. A `repr_c_struct` is a layout mirror, reinterpreted from the source struct's bytes, so its field types are a layout fact — `Box<T>` (a pointer) really is a different C type from `T` (inline) and the spelling is load-bearing by construction. That is the one place the usual split inverts, and it is why #230's headline example (`Payload.label`) is not a defect. Reusing a mirror's spelling test in a converted position is how the rule breaks. A tagged-union payload is converted, and took its opaque-pointer arm from the `Box` in the spelling: `Option<Box<Handle>>` crossed as `handle_t *` while `Option<Handle>` — the same optional handle to every destination — was REFUSED, its structural output marker (`()`) unable to pass the converter-agreement check. An erased wrapper decided expressibility, the same defect shape #292 found on the jnigen side. The arm asks the declaration now, off the model. All three spellings — `Option<Box<Handle>>`, `Option<Handle>`, `Handle` — present `*mut handle_t`, and their converter bodies differ: the boxed one hands over the box it has, the others are boxed by the converter. The C type follows `kind`; the conversion follows the syntax. Pure addition: `mirror_field_wire` is still consulted first, previously-refused shapes now resolve, and the regen is byte-identical against the merge base. Refs #292, #230.
…297) #294 called it definitive on the grounds that the only alternative was spelling a Rust wrapper into a JNI symbol. That is a false dichotomy: keying the helper trio on the CANONICAL element gives one trio per Kotlin class, with the element's wrapper applied where the Vec is consumed. #296 has the sketch. The cost of leaving it is not correctness but a silent downgrade — a `Box` the model erases turns raw scalar leaves into a per-element JObject plus a field read per field. Refs #296.
…ge A) (#298) `immediate_edges` asked for a `&syn::Type` and opened by re-keying it — twice, once for the structural children and once for the declared fields. So `resolve.rs`, `order.rs` and `register_type_inner` each spelled a key into tokens purely so the callee could undo that: a normalize pass and a token render per call, to arrive back where it started. The most common use of `TypeKey::to_type()` was undoing itself. It takes a `&TypeKey` now. A table lookup takes an identity. The same round trip, one layer out, ran at every `key.to_type()` fed straight to `reading_of` — which is `reading(&TypeKey::from_type(..))`. Those become `reading(key)`, the route #284 already moved jnigen's `convert_crossing` to, and any spelling they still need comes off the reading rather than off the key. Two sites keep a spelling and say why: `order.rs`'s `plan_edges` needs real tokens for `extract_fn_trait_args`, and cbindgen's selector chain still takes `&syn::Type` — both now read them from the cell the registry already holds, so nothing is re-derived from a key. `to_type()` is not removed here and `TypeKey` is unchanged: this proves the `reading(key)` route before anything depends on it. 44 call sites to 31, all of it deletion. Verified: 631 lib tests, `cargo test --all --all-features`, clippy on 1.85.0 and stable, and regen-check byte-identical after a forced rebuild plus covertest-kotlin's 49-section JVM harness.
* A declaration keeps the type it was written with (#291 stage B) `ptr_class!(Foo)` receives a real `syn::Type`, reduces it to a key, and throws it away. Everything that later needed those tokens back — to `intern` the type, to spell `Into<#target>`, to say whether the build script path-qualified it — asked the KEY to reproduce them. That is backwards: the declaration is where the type came from. So declarations carry `Origin<syn::Type>` now, the model's own convention for a node's tokens, at `SourceLocation::default()` — the sanctioned placeless location for something a build script authored rather than a captured file. `RegistryBuilder::export_type` takes the type instead of the key, like its sibling `cross` already did, and `Declared::types` / `Decompositions::replaces` carry the spelling beside the identity. That is what unblocks the two sites a key genuinely could not serve: the qualified-declared-types diagnostic needs multi-segment path STRUCTURE, and the declared-type scan needs real tokens for `intern` — for a type that is in no table yet, so `reading()` has nothing to answer with. Both canonicalize explicitly at the point of use, which is what the key was silently providing; the comments say so. The same applies to every declare-phase consumer. `build_expansions`, `build_deconstructors`, `convert_input_body`, `build_sum_decons` and `validate_split_declarations` run while only a `RegistryBuilder` exists, where `reading()` would legitimately answer `None` — swapping them to it would have been a silent semantic change, not a refactor. They read their own decl. Two sites go the other way, to `reading()`, because they are past the declare phase and the sibling arm beside each already did: `SpecKey:: WholeFolder` in `derive_iface_spec` (which is contractually a pure function of its key, so a side channel was not open to it), and cbindgen's callback structs, which now read the argument types their declaration recorded rather than rebuilding them from a `Vec<TypeKey>`. 28 `to_type()` call sites to 9, and every one that remains is a name lookup or the idempotence test — stages C1 and D. Verified: 631 lib tests, `cargo test --all --all-features`, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, and covertest-kotlin's 49-section JVM harness. * Review: exporting a type twice keeps the first spelling `declared.types` was a `HashSet<TypeKey>`, so a repeated `export_type` was first-wins on the identity. Turning it into a map made `insert` overwrite the stored spelling — last-wins, and only for the spelling, which is the one thing about the pair that is not fixed by construction. `register_class` already documents keeping the first for a reopened declarator; `export_type` says and does the same now. Also from review: the fixture types in `write/tests.rs` were still named `key_a`/`key_b` after becoming `syn::Type`s, and `declared_origin`'s intra-doc link pointed at `crate::core::RegistryBuilder`, which is not re-exported there. Verified: 631 lib tests, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49.
* A key answers what it is called (#291 stage C1) Eight sites asked `to_type()` for a whole `syn::Type` and then threw all of it away but one ident. They were not asking for syntax; they were asking the key what it is called, and a key can answer that itself. TypeKey::ident() -> Option<syn::Ident> // bare_path_ident's rule TypeKey::short_name() -> Option<String> // last segment, generics and all Two, because the incumbent walks genuinely differ: `bare_path_ident` refuses a type carrying generic arguments, while the Kotlin class-name derivation reads `Publisher<'static>` as `Publisher` — a declaration writes the latter and means the class. Keeping one accessor would have had to pick a winner and silently change one set of call sites. Both read the canonical string, and that is deliberate rather than a shortcut: `canon` is a token-stream rendering, so tokens are space-separated (`Vec < u8 >`, `& Foo`, `a :: Foo`), which puts a path's head before the first `<` and its last segment after the last `::`, with `syn::parse_str::<syn::Ident>` as the total validator on the far end. Reparsing the type instead would make a NAME depend on a serialize-then-reparse round trip, which is the dependency #95 removed. `key_name_accessors_match_the_syn_walks` is the warrant: sixteen shapes — bare and qualified paths, generics, references, slices, arrays, tuples, unit, raw pointers, trait objects, fn pointers — each asserted equal to the walk it replaces. It also pins the one documented limit, a qualified-self path answering `None`; `scan_declared_items` refuses one, and refusing beats guessing for a shape this cannot read. The boundary ledger moved 120 -> 117 and `kotlin_emit.rs` left it entirely: these were real source-syntax classification sites, not just call-site noise. Verified: 634 lib tests, `cargo test --all --all-features`, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, and covertest-kotlin's 49-section JVM harness. * Review: a path segment is not the last thing before a `<` The string walk split at the FIRST `<` and took the last `::` of what was left. That gets `Vec<a::B>` right — the `::` belongs to the argument — and `a::Foo<u8>::Bar` wrong: `short_name` answered `Foo`, and `ident` answered `None` because the canon contained a `<` at all, where both syn walks answer `Bar`. Generic arguments on a NON-FINAL segment were the case the split could not see, and the migrated Kotlin lookups would have resolved the wrong name or silently skipped the declaration. The walk is nesting-aware now: `path_segments` tracks angle depth, splits on `::` only at depth 0, and takes each segment's arguments from that segment. `ident` then applies `bare_path_ident`'s actual rule — arguments on the LAST segment only — instead of on the whole string. Two things fell out of doing it properly: * A qualified-self path no longer needs its documented exception. syn keeps only `Item` of `<T as Tr>::Item` in `path.segments`, and skipping the leading group reads it the same way, so the accessors now MATCH the walk there rather than declining. * The `>` of a bare fn's `->` is an arrow, not a bracket. Miscounting it makes the `::` inside `Vec<fn() -> a::B>` look top-level, which would answer `B` for a type whose name is `Vec`. Both reviewers also noted `ident` did redundant work — it built a String via `short_name`, then reparsed it. It shares the one scan now and parses each segment once. SHAPES grows from 16 to 24, with the three cases above plus the ones that pull against them, and three focused tests pin the last-segment rule, the qualified-self tail, and separators inside arguments. Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49.
* A key is only an identity (#291 stage D, closes #291) The last three readers, then the channel itself. `option_depth` peeled `Option<…>` tokens to count layers the model had already counted: `TypeKind::Optional` is produced for exactly `Option<T>`, and `optional_inner()` names the layer. It takes the reading now, and its two callers hand over the readings they were already sitting next to. `KotlinMeta::value_rust_key` held a `TypeKey` and had exactly ONE reader, which immediately spent it on `to_type()`. It was never an identity here — only a detour through one — and both producers have the `syn::Type` in hand. It is `value_rust_type: Option<syn::Type>` now, carrying the same canonical form it always yielded. The idempotence assertion in `typekey_normalizes_equivalent_spellings` re-keyed `to_type()`, which was really a claim about the parsed form a key kept beside its string. A key keeps no such thing; the next line's string round trip is the whole claim and it stays. Then: pub struct TypeKey { canon: std::rc::Rc<str>, } `from_type` stops allocating the second `Rc`. `parse` still parses, to VALIDATE, and discards it. `Eq`/`Hash`/`Ord`/`Debug`/`Display` never read anything else, so nothing about identity or error text moves. What this closes is not a miscompilation — none was known. It is that the type system permitted a category of mistake: spell a type nobody classified. #280 sealed `TypeRef` so only the model may mint a reading, and a key that hands out tokens walked straight around that seal. The route from a key to syntax is `Conversions::reading` + `TypeRef::syntax`, and now it is the only one. 44 call sites to 0, across four PRs, with the generated output byte-identical at every step. Verified: 634 lib tests, `cargo test --all --all-features`, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, and covertest-kotlin's 49-section JVM harness. * Review: say what a missing reading means instead of asserting it cannot happen `c_domain_niches` carried a comment claiming every crossing key has a cell, next to a `filter_map` that would have silently dropped one if it did not. Review is right that a claim the code does not check is worse than no claim. It is not an `expect`, though. A crossing with no reading contributes no demand, and that is an ANSWER: the niche allocator reserves values no sibling conversion can produce, and a crossing the registry never entered has no conversion to produce one. So the arm is an explicit `0` — the same answer jnigen's twin at `conversion_domain_niches` already gives — and the reasoning lives in the code rather than in a claim a `filter_map` was quietly leaning on. Also from review: the `classify_return` comment still said the peeled type "comes straight off the stored key". It comes off a stored `syn::Type` now, and the fallback beside it is not a miss — the field is `None` exactly for plain values and arity-0 converters, which have no inner identity to peel to. Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49.
…riptor (#289) (#302) * jnigen: the jobject decoder reads the element, and finds a wrong descriptor `struct_input_body` took the `syn::ItemStruct` and walked `syn::Fields::Named`, while its caller was already holding the `flat::Struct` that `struct_type()` handed back — and `flat::Field::ty` is a `TypeRef` the model classified when it parsed the item. So the whole-object `.jobject_input()` decoder re-derived by token what the element had already answered, four times per field. It takes `&flat::Struct` now and asks the optional layer ONCE, the way `build_flat_struct_node` has since #294. The name comes off `TypeKind::Named`, "is it a run" off `sequence_elem()`, the enum probe off `is_kotlin_enum_reading` — each with the precedent #288 set in `struct_plan.rs`. **This one is not output-preserving, and that is the finding.** `WrappedFields { boxed: Box<Option<i64>>, plain: Option<i64> }` is the fixture #294 added because those two fields MEAN the same thing. Kotlin declares both `Long?`. The old emitter asked JNI for: "boxed" -> Ljava/lang/Object; "plain" -> Ljava/lang/Long; `option_inner_type` reads the last path segment, so `Box<Option<i64>>` answered "not optional", the descriptor chain fell through to its `Object` fallback, and the twins diverged — the #273 signature exactly. `GetFieldID` requires the field's EXACT declared descriptor, so that lookup throws `NoSuchFieldError`. The golden now says `Ljava/lang/Long;` for both. It was never observed because `JObject_to_WrappedFields_*` is emitted and never called — this fixture crosses via the flatten path, and its siblings appear seven times each. Any `.jobject_input()` data class with a wrapped optional field would have hit it. Two residuals #294 left inside the converted fn go too: an `is_kotlin_enum` on a spelling three lines from the reading, and a `reading_of` re-looking-up a `&TypeRef` already in hand. `FlatFieldNode::Value::direct_handle` becomes `Option<Box<syn::Type>>` carrying the handle target the plan peeled, instead of a `bool` beside a spelling the renderer re-peeled with the same last-segment test — a `Box<Option<T>>` handle field would have been handed the wrong `Box::from_raw` target. The node stays token-carrying; it is an emission IR, and the answer travels from where the reading was. Ledger 117 -> 116, jnigen census 9 -> 4. `types_util` does NOT move and was never going to: `option_inner_type` keeps callers in struct_out, trait_impl, fold, fn_plan and wrapper. The remaining four are the sum side, which needs `Type::Variant` rather than the `syn::ItemEnum` `enum_item` hands back — the rest of #289. Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, covertest-kotlin 49/49, and regen-check clean apart from the one diagnosed descriptor. * Review: a struct keeps its own constructor delimiters The `syn::Fields::Named` guard this walk replaced refused a unit struct by returning `None`. The per-field name check that replaced it cannot: an empty struct has no field to refuse, so the loop fell straight through to a hard-coded braced initializer and emitted `myflat::Unit {}` for `pub struct Unit;`. That is not Rust. `flat::Struct` does not record whether its fields were named — that is spelling — and `Struct::spell` is the one place those delimiters are chosen. It is the exact dual of the `Alternative::spell` the sum decoder uses so `enum E { B() }` is written `E::B()`; I used the model's helper there and hand-rolled the braces here, which is the whole defect. `empty_structs_keep_their_own_constructor_delimiters` covers it, and fails without the fix. A tuple struct is deliberately absent from it: the model reads one as an `Extern`, so `Flat::struct_type` answers `None` and it never reaches this decoder — declaring one as a `jobject_input` data class fails to resolve instead. Goldens byte-identical: no in-tree example declares a payload-less jobject_input data class, which is why the shape had no signal. Verified: 637 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild, covertest-kotlin 49/49.
`sum_input_body` took the `syn::ItemEnum` and ran two zips to get back to
per-field types: a `SumSpec` derived from the item, paired against the
item it was derived from. `Alternative` already IS that pairing — name,
index, and a `Vec<Field>` whose `ty` is a `TypeRef` — so both zips and
the `SumSpec` go, and the payload reads ask the model.
It needs a different accessor to get there. `Flat::enum_item` hands back
only the `syn::ItemEnum`, deliberately: its own doc says a consumer that
acts on the Variant/Enum distinction should ask `declared_type`. Both
`sum_input_body` and `build_flat_sum_field` do that now and match
`Type::Variant`.
The constructor's delimiters come from `Alternative::spell`, which is the
one place they are chosen. That is not a tidy-up: `Alternative::is_empty`
is the GROUP question — `B`, `B()` and `B {}` are all empty by it — and
Rust demands the delimiters wherever the last two are named, so a
three-arm `syn::Fields` match was the only thing standing in for a helper
the model already owns. `Field::bind` shapes each init the same way.
`sum_field_prop_name` takes a `&syn::Member` instead of a `&SumField`.
The member is the whole of what the name depends on, so a caller holding
a `flat::Field` asks `Field::member()` and a caller holding a `SumField`
reads its own — one derivation for both, rather than a second convention
that could drift from the sealed-interface emitter's.
**flat_input.rs is now at zero on every count.** No `option_inner_type`,
no `reading_of`, no `bare_path_ident`, no `pat_match_top`, no `SumSpec`,
no `syn::Fields` — the file comes off the spelling census (4 -> 0, and
18 -> 0 across #294 + #302 + this).
The boundary ledger does NOT move, and should not: its four remaining
entries in this file classify `entry.destination`, a wire type the
adapter itself produced, which is legitimately its business rather than a
reading it should have asked for.
`SumSpec` stays for now — it still has callers in `struct_plan`,
`kotlin_emit` and `sum_out`, and retiring it crate-wide would put the
Kotlin emitters and the sum OUTPUT path in a PR about input decoding. It
owns one thing the model does not, the leaf-naming convention, so that
has to be rehomed rather than deleted.
Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
BYTE-IDENTICAL after a forced rebuild, covertest-kotlin 49/49.
`encode_sum_group` built each arm's pattern by branching on
`variant.fields.first()`, so an alternative with no fields took the
`None` arm and was spelled bare. For `enum E { B() }` and `enum E { B {} }`
that emits `myflat::E::B`, which is E0533 in pattern position: a
zero-field tuple or struct variant still needs its delimiters.
Branching on the first field cannot answer this, because an EMPTY
alternative has no first field to branch on — the same shape as the empty
struct that had no field to refuse, which is how #302 came to emit
`myflat::Unit {}`. That is now three instances of one defect class in
this area: a constructor caught in review (#302), a constructor avoided
by using the model's helper (#303), and this pattern, which nothing had
found.
`Alternative::spell` is the fix, and its doc names the case: "the one
place those delimiters are chosen — for match patterns and constructors
alike, in either direction". This is the pattern half of that sentence;
`Field::bind` shapes each binding the same way.
Getting there needs the element rather than the item, so the arm list
comes off `Flat::declared_type` -> `Type::Variant` and walks
`alternatives`. `enum_item` hands back only the `syn::ItemEnum`,
deliberately — its own doc says a consumer acting on the Variant/Enum
distinction should ask `declared_type`. The tag is `alt.index`, which is
what `SumVariant::tag` was a copy of.
`empty_sum_alternatives_keep_their_own_pattern_delimiters` covers all
three shapes and fails against the old branch.
Goldens byte-identical: no in-tree fixture declares an empty non-unit
alternative, which is exactly why this had no signal.
Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49.
* core: delete `SumSpec` — the model already describes a sum `SumSpec`/`SumVariant`/`SumField` described a data-carrying enum as a tag plus one leaf group per variant. `flat::Variant` describes the same thing, and better: `Alternative` carries the name, the declaration-order index and a `Vec<Field>` whose `ty` is a classified `TypeRef`, where `SumField` kept a bare `syn::Type`. It was #211's own thesis sitting in `api/core`. Every live field had an exact model equivalent: v.ident -> alt.name v.tag -> alt.index v.is_unit() -> alt.is_empty() f.member -> field.member() and four fields had no reader at all — `SumSpec::{key, source}`, `SumField::{name, ty}`. **`SumField::name` is the one that matters, because two comments and I said it was the blocker.** `emit/sum_out.rs` and `kotlin_emit.rs` both claimed "`SumSpec` owns the leaf-NAMING convention, which is jnigen's own", so retiring it would need that rehomed first. Nothing read it. jnigen names its slots with `sum_field_prop_name` + `sum_slot_fragment`, a different convention living in `struct_plan.rs`. `SumField::ty` was dead for the same reason one layer down: every site already took `alt_field.ty` off the element sitting beside it. The doc's other premise was also unmet — "both adapters read one definition instead of growing a private one each" — cbindgen never used it. The `#[allow(dead_code)]` on all three structs was the tell. Three of the four remaining sites already held the `&flat::Variant` and zipped `SumSpec` back against `sum.alternatives`: derived from the item, then re-paired with the element it was derived from. That is the shape #303 removed from `flat_input.rs`. The fourth reached `Flat::enum_item` and moves to `declared_type` -> `Type::Variant`, as #304 did. Also gone: `kotlin_emit`'s `sum_field_property_name`, a second copy of `sum_field_prop_name` keyed on the deleted type, and a redundant `enum_item` lookup in `struct_plan` that sat beside the `declared_type` doing the real work. Ledger and census do not move, and were not going to: `SumSpec` names no `syn::Type` variant, so the ledger never counted it. The five deleted `types_util` fixtures tested the declaration-order tag, which `flat/tests/acceptance.rs` already asserts on `Alternative::index`. Verified: 633 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc` clean of the two intra-doc links this orphaned, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49. * Review: one place turns an alternative's index into its wire tag Three sites did `alt.index as i32` — the leaf's `group`, the Kotlin `when` arm, and the Rust `match` arm — and a fourth formatted `alt.index` straight into a `when` arm. All four have to agree, and they agreed by coincidence rather than by construction. `sum_tag` is the one place. Review asked for a checked conversion. It is deliberately not one: the index counts alternatives of a single enum, and an enum with `i32::MAX` variants is not something rustc can be handed, so `try_from(..).expect(..)` would put a panic in the working path for a state the compiler cannot produce. The bound and that reasoning are on the function. The model keeps `usize`, which is the reason the conversion exists at all and belongs here. `i32` is `jint` / Kotlin `Int` — a destination-language width, and `core::flat` states language-neutral facts. cbindgen reads no model `.index`; the tag width is one adapter's concern, so it lives in that adapter, beside the `UnfoldLeaf::group: Option<i32>` it feeds. Cheap to have done otherwise — every other read of a model index is a doc invariant, an assertion or a `format!` — but it would have put a wire type in the model to save one cast. Also from review: the `enum_discriminant_values` rustdoc link said `Alternative::index` and pointed at the struct. Verified: 633 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49.
* core: a `SumTag` selector registers the sum it names (#282) #282 asked one thing: is the `SumTag` leaf boundary-crossing data, so its `out_ty` should be registered, or adapter-only metadata that stays deliberately unregistered? Decision: it gets a cell. Most of the issue had already landed and its text is stale — `sum_out.rs` stopped composing `TypeRef::named(enum_ident)` when #280 sealed the composers, and the leaf now carries `Variant::type_ref()`, the reading the DECLARATION stored. What was open is registration. `require_output` could not serve it. That is `register_type_recursive(.., root = true)`, and a root DEMANDS a converter — which a sum has no whole-value output form of, so requiring one fails resolution. Pulling the tag's `i32` in that way is the original reason `has_converter()` exists. So registration and demand needed separating: `Registry::reference_output` registers without demanding, and both leaf loops now split on `has_converter()` instead of filtering by it. **The invariant, now stated where it is checkable.** Every leaf's `out_ty` has a table cell; only a converter-bearing leaf is a root. A cell says the type entered the pipeline, a root says the binding asked for it directly, an entry says one resolved — three claims, and a `SumTag` leaf makes only the first. Written on `has_converter`, on `LeafSource::SumTag`, and on `TypeRef`, whose doc already said a reading claims no converter and now says it claims no cell either. **What this buys, which the old test proves.** The invariant held before only because jnigen happens to declare the sum through `export_type`. `unfold/tests.rs`'s assertion read `!...is_some_and(|c| c.root)`, which is also true when the cell is ABSENT — and absent is what it was, since that fixture's registry declares nothing. It passed for the wrong reason and could state neither half. It now asserts the invariant over every leaf of the plan, and fails against the old filter. The end-to-end claim the acceptance asks for is a new test against a real `Registry`: for a declared sum, a cell both ways, root cleared both ways, an input entry (the whole-`JObject` decoder) and no output entry. That asymmetry is the design — Rust → Kotlin is flattened, always. Goldens byte-identical, as predicted: `crossings()` seeds from every table key, so a NEW cell would add a crossing — but jnigen's sum already had one from its declaration, so nothing entered the order. Ledger and census do not move; this adds no `syn::Type` match and no spelling-helper call. Verified: 634 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`, regen-check after a forced rebuild, covertest-kotlin 49/49. * Review: the fixture's selector carries the sum, so the test pins #282 `reading_sum_decon` said it mirrors the JNI synthesis and gave the tag leaf `out_ty: i32` — the tag's WIRE type, where `synth_sum_leaves` stores the sum itself. So the registration assertion proved only that *some* converter-free leaf gets a cell, not that the selector registers the sum it names, which is the whole of #282. I noticed that divergence and wrote around it instead of fixing it, and the review is right that the acceptance test cannot cover for it: `sealed_class!(Reading)` creates the `Reading` output cell through `export_type` whether or not the leaf registers anything. Measured — it passes against the old filtered loop. With the fixture carrying `tref(Reading)`, `sum_return_is_a_fixed_builder_plan` fails against that loop with `leaf `tag` registers its out_ty`. That is the behaviour #282 decided, pinned. The acceptance test keeps its own claim — a declared sum's three-part registry state, including the input/output entry asymmetry — and its doc now says what it cannot claim, so it is not mistaken for the guard later. Verified: 634 lib tests, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49.
* jnigen: one reading of the movability rule (#223 item 1) `plan.rs` says of `steps_are_movable`: > This is the one place the rule is written… Two readings of it would > drift, and the disagreement would be a borrow handed to an owning > converter. `reach_leaf_flat` was the second reading, spelled `path.iter().all(PathStep::is_plain_field)`, against `encode_plan_leaves`' `steps_are_movable(&path)`. They already disagreed: the rule permits a TRAILING optional field — a `None` arm still hands the whole `Option` over by value — and the restatement did not. So where `place_is_owned` granted an owned `out_ty` on the strength of the rule, the emitter projected `(&(&__src.a).b).clone()` and handed a borrow to the owning converter that `out_ty` had selected. PR#221's P1, exactly, one path shape away. Its comment defended the restatement: a trailing optional cannot reach return delivery, because a nullable leaf is routed to callback delivery in `single_return`. True — and that is the shape of the hazard, not a defence against it. A local restatement can disagree with the rule for as long as an invariant somewhere else keeps the disagreement unreachable. The optional-step guard beside it had the same shape of hole. It asked its `path` PARAMETER, and `wrapper.rs` rebases onto a hoisted local and hands over the remaining suffix — `Hoisted::innermost` having stripped the prefix that bound it, optional step and all. So the guard passed exactly when the hoist was the conditional one, which is the case that cannot compose: an `Option<T>` local with a field read hung off it. It asks `leaf.path` now. Both were unreachable, and neither was unreachable for a reason the emitter states. **No test called a reach function directly** — every pin was an end-to-end string match on generated Rust, which is why a latent divergence had no failing test. Three now do, and each fails against the code it replaced; the movability one reports the defect verbatim: `got '(& (& __src . a) . b) . clone ()'`. They ask `test_util::reading` rather than `Flat::classify`, which #280 sealed to `api::core` — a test under `api::lang` meeting that boundary is the boundary working. Goldens byte-identical: neither divergence is reachable today. Verified: 637 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild, covertest-kotlin 49/49. * Review: the fixtures build leaves the resolver can produce `leaf()` set `LeafSource::Field` for every fixture, including the two identity leaves and the one with a `Call` step. Production pairs the source with the path shape: an identity leaf is `Accessor` (`unfold.rs`'s `DeconRecord::Identity` arm), and `Field` belongs only to the synthesized by-value `data_class` decomposition, whose paths are field idents and never calls. So the fixtures exercised a leaf the resolver cannot build. Not cosmetic, because `source` decides the terminal treatment: a `Field` leaf is CLONED out of the place it reached. That clone was landing in the movability test's failure output, which reported got `(& (& __src . a) . b) . clone ()` for a defect that, on the accessor leaf this actually models, produces got `& (& __src . a) . b` Same assertion, same discrimination — both tests still fail against both old implementations — but the evidence now shows what the divergence really emits rather than a clone the shape would not have. `source` also stops being a value every fixture happens to share: `a_field_leaf_is_cloned_out_of_its_place` covers what `Field` means, so the parameter is load-bearing in the tests as well as in the emitter. Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49. * jnigen: one name, one enum question (#223 cheap wins) Three places where one question had two implementations — #223's thesis, in the naming and classification layer it does not list. **`sum_slot_name` was a byte-for-byte copy** of `sum_slot_fragment`, same lower-first-char plus `_` join. `kotlin_emit` called the copy at one site and the shared `sum_field_prop_name` at two others, so a slot name and the property inside it came from different files. Deleted. **`classify_field` asked the spelling where the model has the answer.** It called `is_kotlin_enum` twice, on two spellings, where `flat_input` asks `is_kotlin_enum_reading`. `builder.rs` documents the difference: a `Box<Priority>` field is `false` for the first and `true` for the second, so a wrapped enum field would classify as a plain leaf and render as its wire rather than the Kotlin enum class — the #273 family, output-side. It asks once now, of `bare_ref`, the already-peeled reading sitting beside it. Optionality stays the caller's fact: `enum_probe` peels `Option` as well as borrows, so probing the unpeeled reading would make `Priority` and `Option<Priority>` indistinguishable and collapse two arms into one. I tried to prove this with a `Box<Priority>` field on `WrappedFields`, the fixture #294 added for exactly this pairing. **It does not resolve** — `TypeKey("Box < Priority >")` is unresolved output-side — and reverting the classification change leaves it failing identically, so that is a PRE-EXISTING capability gap and not this change's to fix. Reported separately; the fixture is not in this commit. The change stands on the question being the right one to ask, not on a demonstration it cannot yet have. **Two camel-casers named one Kotlin property.** `render_data_class_source` DECLARED it with `kt_snake_to_camel`; `flat_input`'s access expression and its `GetFieldID` slot name used `util::snake_to_camel`, which additionally lower-cases the first character. They agree for a conventional lower-snake field and only for that — a field spelled `Xyz` is declared `Xyz` and read as `xyz`, and `GetFieldID` for a name that is not the declared one fails at runtime. One `kotlin_property_name` now serves the declaration and both readers. `snake_to_camel` stays where it names PARAMETERS, a namespace with no declaration to match; `symbols.rs`'s mangling warning already used the kept caser and passes pre- and post-mangle names deliberately, so it needed no change. Goldens byte-identical for all three, as expected: the two functions were textually identical, no in-tree field name is unconventional, and the enum divergence needs a shape that does not currently resolve. Verified: 637 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild, covertest-kotlin 49/49.
…310) * jnigen: the transparent bridge gets its outbound half (#309) The model erases `Box` and `Cow` deliberately — `Box<Priority>` IS `Priority` to every destination language. #294 gave the input selector a last resort for the case no layer arm claims, a wrapper over a TERMINAL. `select_output_type` never got the twin: arms 1-3 run and it returns `None`. So an erased wrapper resolved inbound and not outbound. `Box<Priority>` was a parameter this binding could take and a return it could not give, for a wrapper the model exists to make invisible. The gap was invisible because a wrapper over something WITH a layer arm resolves both ways through that arm — `Box<Option<i64>>` works, which looks like proof `Box` is handled — and `Box<String>` works too, because the `Str` arm dispatches on `kind()`. Only a wrapper over a plain `TypeKind::Named` needs the bridge. One arm covers `Box<Handle>`, `Box<enum>` and `Box<DataClass>` alike: `output_terminal` misses all three the same way, by keying on the SPELLING, so no config sits under `Box < Priority >`. The dual inverts two lines, because the wrappers come off rather than go on — `read_through_erased_wrappers` was already the operation, and is already used for this job in `emit/wrapper.rs`. Everything else is direction-independent: `subs`, `destination`, `niches` and `metadata` mean the same thing either way, and inheriting the inner's metadata is what keeps `Box<Priority>` presenting as the Kotlin enum class rather than losing it behind the wrapper. Both guards carry over, the second with its own outbound reason: a borrow's output route is the clone-into-a-fresh-handle arm, which builds its wire from a reference and hands back no owned value to read the wrapper off. Inbound the same guard is about `E0106` — the shapes coincide, the reasons do not. Placement is symmetric with the input side and deliberate: step 4 sits after every layer arm, so nothing that resolves today changes route. It is not reached for `Optional`/`Sequence` failures, which return early exactly as they do inbound, because a wrapper over those is already bridged inside the arm. Measured against the shape that prompted #309: it now emits let __inner = *v; Priority_to_jint_447102d2(env, __inner)? Goldens byte-identical — this adds routes for shapes that previously reached `None`, and no in-tree example has one yet. The fixtures come in their own commit. Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild. * jnigen: the transparent bridge runs the inner's stages (#309) `input_transparent_bridge` called the inner converter's function directly and left `pre_stages` empty. Every other composing arm goes through `composed_inner_input` / `composed_inner_output` for one reason: a `convert!`-declared type reaches its Rust value through those stages. So a `Box` over one skipped them. Not a subtly wrong value — **the generated crate does not compile**: let __inner = jlong_to_u64_4384a5d6(env, v)?; ::std::boxed::Box::new(__inner) // ^^^^^^^ expected `Duration`, found `u64` [E0308] `boxed_duration_echo` is the fixture, and it is load-bearing rather than illustrative: reverting this commit with it in place fails the build with that error. `Duration` is `convert!`-declared with `jlong -> u64 -> Duration`, so the wrapper sits over a chain rather than a single call. Both directions now emit the full chain, and the Kotlin exercise runs it at JVM runtime — a `Box<Duration>` crosses exactly as a bare `Duration` does, which is what the model erasing the wrapper is supposed to mean. The outbound half added in the previous commit was written this way from the start; this is its inbound peer, in its own commit because it is a bug fix rather than the new capability. Goldens move by the fixture alone (+117 lines, all additions). Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild, covertest-kotlin 49/49 including the new exercise. * core: a wrapped spelling is ORDERED after the spelling it delegates to (#309) Whoever converts `Box<T>` does it by delegating to `T`'s converter and putting the wrapper back. That is a real dependency, and the `kind` walk cannot see it: `Box<T>` classifies as whatever `T` is, so the two share a classification and differ only in spelling. `subs` said "this is required". Nothing said "this comes first" — and `convert_with` is a SINGLE pass in dependency order, so a delegating converter needs its inner already built. `immediate_edges` now yields the stripped spelling as an edge when the reading has erased wrappers. **The inbound bridge has been resolving by alphabetical luck since #294.** Roots are visited in key order, so `Box<Payload>` resolved because some other root's fields happened to pull `Payload` in earlier. Measured on a fixture where that luck runs out: renaming `Priority` to `APriority` makes `Box<APriority>` resolve and leaves `Box<ZSample>` unresolved, purely because "ZSample" sorts after "Box < ZSample >". A capability that depends on the alphabet is not one. `an_erased_wrapper_over_a_terminal_crosses_both_ways` is the acceptance test for #309 as a whole, and needs both this and the outbound arm: with either reverted it fails, naming the unresolved wrapped spellings. It asserts all three terminal kinds together — enum, handle, data class — because they miss the terminal lookup the same way, which is the claim that one arm covers them all. It also asserts the wrapped and bare enum fields present as the SAME Kotlin type, which is what erasing the wrapper is supposed to mean. Goldens byte-identical: every in-tree example already resolved, so this changes no output. It replaces luck with an edge. Verified: 639 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild. * jnigen: the fixture that could not be built (#309) `WrappedFields` gained `Box<Priority>` beside `Priority`, the pairing it already carries for `Box<Option<i64>>` / `Option<i64>`. That first pair rides the `Optional` layer arm and always worked; the second classifies as `Named`, no arm claims it, and outbound there was no route at all — which is what #309 is. This is the fixture whose failure to build FOUND the gap, while adding a demonstration for #308's `classify_field` fix. It now builds, and it proves both: public data class WrappedFields( val id: Long, val boxed: Long?, val plain: Long?, val boxedEnum: Priority, val plainEnum: Priority, ) `boxedEnum` presenting as `Priority` rather than as its `Int` wire is #308's change; that it presents at all is #309's. #308 landed correct and undemonstrable because no `Box<enum>` field could be built to show it — this is its first end-to-end evidence. The Kotlin exercise weighs the two enum fields against each other, so the claim under test is that a wrapped and a bare spelling of one type behave alike, not merely that the wrapped one compiles. Goldens move deliberately: +131 lines, the new capability. Verified: 639 lib tests, clippy on 1.85.0 and stable, fmt, regen-check after a forced rebuild, covertest-kotlin 49/49 including the new checks. * Review: a doc comment goes back to the function it describes `/// **Input** wrapper shape …` had been stranded since #294 inserted the transparent bridge between it and `input_wrapper_shape`, which has had no doc of its own ever since — while `output_wrapper_shape`'s doc calls itself "the dual of `input_wrapper_shape`", pointing at the undocumented one. Adding the OUTBOUND bridge moved that fragment onto an output converter, where a header reading "**Input** wrapper shape" is not merely stale but a contradiction. Review caught it there; the fix is to give it back to its owner rather than to relabel it. Also from review: the acceptance test claimed "each field type is wrapped and unwrapped in one struct", and only the enum is. The handle and the data class are wrapped only, and deliberately — what they show is that ONE arm serves every terminal kind, where a bare twin of each would test the terminal lookup instead of the bridge. The comment says that now. Verified: 639 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`, regen-check byte-identical after a forced rebuild.
* core: a type is its syntax — `TypeKind` stops classifying
`TypeKind` was a **destination-neutral classification**: one variant per
concept a target language would act on, several Rust spellings folding
into each. `String` and `str` were one `Str`; `Vec<T>` and `[T]` one
`Sequence`; `Box<T>` and `Cow<'_, T>` disappeared into what they wrapped.
It leaked, and not at the edges:
* `&T` earned a layer of its own while `Box<T>` was declared transparent
— two wrappers, opposite treatments, on no principle either adapter
shared;
* `Cbindgen` picked its C type off the Rust spelling regardless, so the
neutrality the kind claimed was not what any adapter used;
* every fold had to be **undone** somewhere. `erased_wrappers()` and
`stripped_syntax()` exist because lowering dropped something a consumer
needed back.
So `TypeKind` is now the subset of `syn::Type` the flat API accepts, and
nothing else. One variant per accepted form:
Scalar Str String Unit
Optional Vec Slice Fallible
Boxed Cow Uninit
Array Ref { lifetime, mutable } Named { id, args } Callback
`RefMode` is gone: `&mut MaybeUninit<T>` is `Ref { mutable: true }` over
`Uninit`, the two forms the source wrote. A lifetime and a generic
argument are kept, because they are what the source wrote.
## The folds did not disappear — they moved to where they are decided
Each is one shared reading, taken on purpose at a call site rather than
baked into every classification:
TypeRef::unwrapped() Box/Cow peeled <- the erasure in lower_path
TypeRef::sequence_elem() Vec<T>, [T], either <- TypeKind::Sequence
behind a wrapper
TypeRef::borrow_target() past an out-param <- RefMode::Out's absorption
slot
TypeRef::is_exclusive_borrow() &mut T, not <- RefMode::Exclusive
&mut MaybeUninit<T>
Old `x.kind()` is exactly new `x.unwrapped().kind()`, which is what made
the ~30 consumer sites in `registry/scan`, `unfold`, `cbindgen` and
`jnigen` a mechanical rewrite rather than a re-reading of each one.
## What it buys
> **The syntax is recoverable from the kind.**
`TypeKind::to_syn()`, checked against `TypeRef::origin.syntax` over the
acceptance corpus by `syntax_is_recoverable_from_kind` — 27 spellings,
token-exact, including `[u8; TAG_LEN]`, `&'a T`, `Cow<'_, [u8]>` and
`Sample<'a, u8, Vec<u8>>`. Two exemptions, each named in a test of its
own: a callback's bound *order*, and a `Group`/`Paren` the lowering sees
through.
The slice still rides along and generated Rust still spells it — it is
exact and free. It is no longer **load-bearing**, and that is the whole
difference: a fact missing from `kind` used to be invisible, because the
syntax was there to cover for it.
## Also
One new refusal falls out: mid-path generic arguments (`a::B<T>::C`) are
`UnsupportedForm`, since `Named` holds the last segment's arguments and a
spelling this model cannot give back must not be accepted. No flat API
writes that shape.
`peel_transparent` stays — the syntax-side peer of `unwrapped`, for an
adapter comparing a spelling it composed against one it has a converter
for. It lives in `flat` so taking a `syn::Type` apart stays inside the
model.
**Did not move**: every generated artifact byte-identical
(`examples/regen-check.sh`), boundary ledger unchanged, 641 lib tests and
22 doctests green, clippy + fmt clean on 1.85.0 and stable.
* core: a `Cow` is a lifetime and a type, in that order
Review (#312): the `Cow` arm checked the number of **type** arguments and
then plucked the first lifetime out of the list, so three spellings that
are not `Cow`s were accepted and reconstructed as `Cow<'a, u8>`:
Cow<u8> no lifetime — not Rust at all
Cow<u8, 'a> the arguments, in the wrong order
Cow<'a, 'b, u8> two lifetimes, where `Cow` takes one
Each has exactly one type argument, so `arity(1)` passed on all three.
That made "every accepted form spells back what was written" false
outside the corpus — and easy to miss, because `GenericArg` had faithfully
retained the whole list right up until the builtin fold dropped it.
`Cow` is the one builtin with a lifetime in its own signature, so it is
the one whose **whole argument list** has to be validated: it is now
matched as exactly `[Lifetime, Type]`, refused as
`WrongGenericArguments { expected: "Cow<'a, T>" }` otherwise. Which lets
`TypeKind::Cow::lifetime` be a `syn::Lifetime` rather than an `Option`,
and `to_syn` emit the one accepted shape rather than choose between two.
Recoverability is an acceptance rule here, not only a property: a
spelling the model cannot give back is refused where it is read. The
other instance is a generic argument on any but the last path segment.
Also from review: `immediate_edges`' `Ref` arm took its child through
`borrow_target().into_iter().collect()`, which would silently truncate
the graph walk if the accessor and the kind ever disagreed. It is an
`expect` now — the invariant fails loudly or not at all.
Tests: the four refused shapes and the two accepted ones, the latter
asserting the round-trip they exist to protect.
This body carries the stage plan and its evidence, and is where stage state is edited. The repo's
docs/language-integration.mdis the design record — what anElementis, why the syntax rides along — and keeps no live counts.Ledger: 202 seeded → 116 now.
api/core11,cbindgen25,jnigen80.L0–L2 done. L4 in progress (#264 the dispatch entry point; #267 the
classify.rsleak; #271 the emitters' representation assumptions; #272 the selector's; #274 nullability + the struct chain; #276 the trait itself; #278 core's plan leaves; #279 the adapter's reading sources; #285/#286/#288 the lookup surface; #293 what a spelling adds over its classification, which found one live miscompilation; #302/#303 the.jobject_input()decoders, which found a second; #304 the sum's match pattern, a third). L5 started early — #276 took the item methods, because they were the last thing keeping the spelling accessors alive; #280 sealedTypeRef, so the model is now the only thing that can mint a reading, and #293 made what a spelling adds over itskindthe model's answer rather than a peel each rebuilding emitter writes; #298–#301 then madeTypeKeyblind, so a key is an identity and no longer a second door tosyn::Type. L2 reopened and closed again by #283, which found a classification driver the ledger cannot see. L3 not started.Parse once, consume elements everywhere — integration map
Umbrella for making every component of prebindgen consume
core::flat'sElements instead of parsing captured Rust itself.#211 remains the authority on
the invariants and the frontend/adapter boundary. This document does not restate
them; it records the design this program follows, what has landed, and the order.
This body is the one place stage state is edited — the first paragraph says so,
and this sentence used to say the opposite ("this file … change the doc, then
re-sync the umbrella"), left over from when the text lived in the doc. The doc is
the design record and keeps no live counts.
The design
An
Elementis two things at once, and the pairing is the whole point:TypeKind, the field list, which of the two enumshapes an item is — that says what the source means, in terms every
destination language shares;
Origin, carrying the exact syntax the node was built from and thesource it arrived in. Every node has one, at every level — item, parameter,
field, alternative, type, array extent.
The two enum shapes are separate entities, because they are numbered differently
and consumed as different constructs:
Why the syntax rides along
The predecessor design (#215)
built a syn-free semantic model and kept hitting one wall: the generated Rust
glue is itself a destination artifact, and it is the only consumer that needs
syntax fidelity. Each time it did, the answer was to model the syntax —
DiscriminantSource::Explicit(syn::Expr),syn::Member,syn::Lifetime,to_syn(), and finally aVariantShapewhose only job was to make generatedRust spell
E::B()instead ofE::B. A model that carries no syntax has tobecome lossless to serve that consumer, which is how a language-neutral IR
turns back into a second
syn.Carrying the original slice costs nothing and removes the pressure, so the
classification stays small and genuinely neutral:
B()vsBAlternative::origin.syntax(viaspell::fields)= 0x07vs= 7EnumValue::origin.syntax.discriminantEnumValue::discriminantNAME(7),jintdecodeAlternative::indexFoo<'a, T>TypeRef::origin.syntaxFoo"TypeKind::Named[u8; TAG_LEN]— spelling / number / const identityTypeRef::origin.syntax/ArrayExtent::value/ExtentSource::ConstBoxinBox<Option<T>>, and theOption<T>under itTypeRef::erased_wrappers()/stripped_syntax()— derived from the syntax, not storedOrigin::location— absent for a synthesized oneThe rule
This is mechanically measured, and needed no new mechanism:
core::flat::boundary(ported from#224) counts variant mentions
of watched syn enums per file, so
quote!(#slice)is invisible to it whilematches!(ty, syn::Type::Reference(_))is counted. The committed ledger is thescoreboard for this whole program.
Size of the problem
Seeded by L0 at 202 classification sites outside the frontend, split
api/core71,cbindgen25,jnigen106. The second population it was seededalongside — 113 reads of the registry's
syn-keyed item maps — is gone:L1.5 deleted those maps, so every one of those reads now goes through the model.
Those are the numbers this document keeps, because a seed is a fixed fact. The
current count is in
boundary.ledger, which is generated, and its stage-by-stagehistory is in #229. A table of
live counts copied into prose here would be wrong after the next merge, and was.
Two things the falling count has taught, which the count itself does not show:
A site leaving is not the same as a site migrating. The largest single drop was
#248 deleting a pattern engine
whose tables held one entry in the whole crate. Nothing was migrated to read an
element; the code holding the sites went away. Both are real progress, and a stage
that does not say which one it achieved is not reporting.
Not every site must go: some inspect types the adapter itself synthesized —
wire types, converter signatures — which is legitimately the adapter's business.
Separating the two populations is not a document to write up front; it is each
entry's fate as it comes off the ledger, with a stated reason in the PR that
moves it.
Stages
Element, and the ledgerFlat: the model, indexed and resolvedRegistryconsumes elementsapi/corestops classifying source syntaxCbindgenconsumes elementsJniGenconsumes elements (the long pole)synL0 — the parser — done (#227)
(syn::Item, SourceLocation)stream — the seamRegistry::from_itemsoccupies, so multi-source composition is unchangedElementper modelled kind plusUnsupported, every element and componentcarrying the syntax it was built from. (There is no verbatim-passthrough
variant: the proc-macro refuses to mark a
use/mod/macro_rules!, sonothing reached one. The exact variant list is L0.5's, below.)
the accepted grammar
ArrayExtent, ported from One frontend for array lengths, and a typed source model that carries extents to C (#210, first step of #211) #212checked_addcases a reconstruction loses (empty delimiters,
0x07, lifetimes, docs)and the component
Acceptance is preserved, not expanded. An item the language cannot express
becomes
Element::Unsupportedcarrying its diagnosis, because the pipeline hasalways scanned a signature only once an adapter declared it, and a source crate
may mark items no binding uses. Only a duplicate name — which no declaration can
disambiguate — fails the parse. Tuple-struct fields stay unmodelled for the same
reason.
L0.5 —
Flat: the model, indexed and resolved — doneL0 produced a
Vec<Element>, which nobody could ask anything. This stage makes ita model, and takes two bullets off L1 in the process.
core::language→core::flat,Language→Flat: the thing beingmodelled is the flat API
Element = Function | Type | Constant | Unsupported, withStruct,Variant,EnumandOpaqueunderType; the type reference becomesTypeRefOpaqueis an entity, declared by#[prebindgen] pub type X = ..— the waya foreign or crate-private handle gets a name in the flat API. This is
the prerequisite for everything below it
FlatBuildercollects,Flatanswers by name:function,declared_type,constant,element, the per-kind iterators,resolveElement::UnsupportedwithItemError::UnresolvedType— so a dangling nameis reported here, by name, instead of surfacing downstream as an unresolved
converter from whichever adapter looked first
&mut MaybeUninit<T>becomesRefMode::Out— an out-parameter is aproperty of the borrow, not a wrapper type, and it is a boundary concept
every destination language has (C's
T *out)asserts they stay closed across both its sources
Cow<'_, T>needed neither an alias nor a grammar addition in the end: it istransparent, exactly like
Box<T>, so it lowers to whateverTis(#236). Both adapters already
treated it as
Vec<T>, which is what made the transparency the honest readingrather than a convenience.
Still open:
zenoh-flatand its two consumers are separate repos. Theirunmarked types — the 26 zenoh aliases, plus
Duration, which is not in theprelude and so needs a marked alias like any other foreign type — need the same
treatment before they parse.
L1 —
Registryconsumes elements — doneThe seam that makes the direction real. Adapters were not touched.
Registry::from_flat(Flat);from_itemsisFlat::builder+from_flat,so both entry points share one parser
registry.flat()), which is how L2–L4reach it: an adapter already has the registry
resolveinjects adapter-declared binding-local fns into
functionsscan_fn_signature's receiver / parameter-pattern /impl Traitguardsdeleted with their
ScanErrorvariants, along withindex_item,check_no_duplicateandfirst_seen_loc:Flatowns indexing andduplicate detection
ParseError::DuplicateNamecarries both crate names, so one authorityproduces the message
Correctness is checked by default, superseding L0's "inert until declared":
ingestion fails on anything the language cannot express, listing every offender at
once so a source crate needing migration sees one list. An opt-out for
deliberately-unsupported elements is #237.
The cost landed in test fixtures: 167 of 524 tests held an item naming a type they
never declared.
test_util::declare_referencedsupplies a marked alias for thosewhere the handle is incidental; the rest were real corrections — a path-qualified
std::time::Durationthat no declaration can name, and two array-length testsasserting shapes the subgrammar dropped in #212.
Still open:
zenoh-flat's 26 unmarked aliases. Until they are marked,zenoh-flat-candzenoh-flat-jnido not generate.L1.5 — the model is the only index — done
L1 made the registry a projection of the model, but it still kept its own copies.
A projection that copies is two stores that can disagree, so this stage deleted the
copies. Not planned as a stage; it fell out of reviewing L1 and is recorded here
because the map should show where the program actually went.
functions,structs,enums,consts,guards,item_origins,source_modules.Flatgrowsstruct_type/enum_item/source_modules, and the registry answersorigin_module,default_module,named_item_identsoff the model. TheSourceLocationhalf of every deleted map entry was provably dead — all44
.get()sites bound it to_(
Flat::lower_signature) and admitted byadd_local_function— otherwise"one index" would be a lie, since a
sig!(..)never passed through the parser(#239): a cell is
TypeCell { subject, root, entry }, the subject being the frontend'sTypeRef.requiredstopped being stored — it was one name over threestorages — and is derived by
resolve. The subject was originally atwo-variant
TypeSubject, the second variant meaning "a type only thebinding authored, with no reading"; L2 found that population empty and
deleted it, so every cell now carries a reading
const _is aGuard, not aConstant(#240): an anonymous const
has no address, so it is not API. Four sentinel
ident == "_"checks hadalready gone dead without anyone noticing — the failure mode a sentinel invites
(#244): the sealed
Nametrait, because
Identhashes viato_string()and has noBorrow<str>—the allocation can be moved, never removed
(#245): the two type
diagnostics had excluded
Externas an artefact of asking the oldstructs/enumsmaps, which had nowhere to put oneFlatowns the type index(#246): the last index
living outside its owner.
from_flatcollapses to check expressibility,store the model. Canonicalization becomes one definition
(
canonical_type, moved intocore::flat::spellingby L2) that both theindex and
TypeKeyderive fromsignature has readings but no file, so
SourceLocation::has_positiongateswhat diagnostics print. Fixed a pre-existing
:0:0:for hand-built streamsas well
What is left in
Registryis now genuinely its own: the two type tables(adapter answers plus roots) and the five adapter-declared plan maps.
L1.75 — the registry becomes describable — done
Also not planned as a stage, and it moves no ledger sites — the count is 167
before it and 167 after. It is here for the same reason L1.5 is: once L1.5 made
the registry a projection with nothing of its own to hide, its API could be
closed, and closing it is what makes a generator for a fourth language writable
by someone who has not read
resolve. Tracked by#251.
(#249) — the five
decomposition callbacks become one handed-over value
(#250): which type
conversions a binding needs, and whether it has them all. Its module doc
had been a list of fields, and a stale one since Flat is the only index; Registry stops keeping a second one #243 deleted them
(#252):
RegistryBuilderand
Registryare two types because being-described and finished are twostates. 13
Prebindgenhooks called from 9 points insideresolvebecomedescribe, hand over the answers, read it. Nothing calls back into thegenerator — not by trait hook, and not by a
next_request/supplypullloop, which is the same protocol with the arrow reversed
(#253): a build script
names one type.
JniGen::builder().source(..).build()replaces theFlat::builder()→Registry::builder()→resolve→write_*dance;FlatandRegistrystop being names abuild.rshas to knowAll of it is on this branch, in one commit. The stack landed PR-into-PR onto
flat-drop-pattern-engine, and #248 squash-merged that branch afterwards, sod845c8f— titled for the pattern engine — carries the registry and generatorredesign too. Do not read the commit log as the inventory:
flat-drop-pattern-enginestill reports 28 commits ahead of
language-integrationbecause a squash recordsno ancestry, while the trees differ by nothing. Diff the content, not the history.
L2 —
api/corestops classifying source syntax — done(#248):
match_pattern,unify,immediate_pattern_children,substitute_wildcards, both ranktables. The general machinery composed converters for any parametrized type;
its tables held one entry in the whole crate,
Result<_, _>, which themodel already names
TypeKind::Fallible. 592 deletions against 124insertions, and the
ConverterImpltail extracted verbatim rather thanrewritten. Ledger 202 → 167
(#257):
registry/walk.rsis deleted and
immediate_edgestakes its children fromTypeKind. Three ofits arms were dead rather than migrated — the grammar refuses non-unit tuples
and raw pointers, and
Group/Parenare transparent. Ledger 167 → 158expansion builds spellings the source never wrote, so
ensure_entryasks thegrammar once, when a cell is born, and stores the reading in that cell.
Flatis consulted, never extended — its index means what the sourcewrote, and a wire-side intermediate is not that
TypeSubjecthad no members left (measured: zero refusals across everyin-tree example and the whole suite), so the enum is gone. A spelling the
grammar really does refuse is now a reported error naming it, rather than a
cell that quietly means less than its neighbours
canonical_type,normalize_type,type_from_identand the rest becomecore::flat::spelling. They decide whatspelling a type has before anything keys on it — the same authority that
decides what it means. Ledger 158 → 154
the twenty sites in
unfoldandexpandthat peeledOption, thenVec,then
&by taking a spelling apart now read the model's arity stack.Ledger 154 → 135
(#263): fourteen sites still
reached into
origin.syntaxfor a fact the element already held — aFunction::retthat is aTypeRef, callback arguments that areTypeRefs.The helpers now take
&TypeRef, so the round trip does not compile. Theledger did not move, which is the finding, not a footnote — see below
#248 is deletion, not migration, and the distinction is worth keeping visible:
35 sites left because their code left. The same caveat applies to the spelling
move, which is a move. Only the last item above is a migration in the full
sense, and it is the one that took the most arguing.
What L2 taught: a peel must match what the consumer can build
Three defects in the layer read, all one root — a peel that answered more than its
caller could represent — and none of them visible to the evidence this programme
usually relies on. The suite passed and regen stayed byte-identical through all
three, because no in-tree example exercises the shapes involved.
Vec<T>matched aTconstructor. Expansion builds one value; its planshape has no iterable arm. A peel that removed the
Sequenceanyway made aVec<T>parameter match aTconstructor, and the wrapper would have handed onereconstructed
Tto a parameter expecting the collection.Vec<Option<T>>read as anoptional inside a run, so a return matched a decomposition target
Tandinstalled a fold — for a type the explicit path next to it refuses outright. Two
paths disagreeing about one return, the silent one winning.
Layerswas a fourth copy ofcore::shape::Shape, whose own module doc saysit replaced three. Encoded as flags, so a caller could only ignore a layer it
could not build; the stack lets it decline by not matching.
What came out of it is the rule, and it outlives the stage: the peel is chosen by
the consumer's capability, not by the type's structure.
TypeRefthereforeoffers both —
layer_stackfor a consumer that implements every layer, andoptional_inner/sequence_elem/borrow_targetfor one that composes exactlywhat it can honour.
What L2 taught twice: the ledger measures the wrong thing for this
The fourth defect was the measurement itself, and it is the one worth carrying
furthest. L2 was first reported done on the strength of the count falling 154 → 135.
Then a review pointed at this, which had survived all of it:
Function::retis already aTypeRefwithkindcomputed at parse time. Themodel handed the answer over; the code reached into
originand derived it again —in six places, with five more re-extracting callback arguments the model held as
TypeRefs, and three digging parameters out of a clonedItemFn.The ledger could not see any of it. It counts variant mentions of watched syn
enums per file, outside
core::flat, so moving a match into one shared classifierdrops the count without changing the data flow. Both facts are real, and they are
different facts:
The fix is a signature rather than a checker —
peel,peel_borrowandreturns_typetake a&TypeRef, so a caller must already hold a reading and theround trip does not compile.
Flat::classifybelongs to the registry, which is theauthority on what a type means because it is the thing that stores readings.
origin.syntaxis read only where a value is stored for emission.So a count is a proxy, and this one has a known blind spot. A stage that reports
only its delta is reporting the proxy. Where a rule can be made structural, it
should be — the deltas L3 and L4 report are worth exactly as much as the invariants
they can point at underneath them.
What L2 taught a third time: a driver the ledger cannot see at all
L2 was reported done twice — once on the count falling 154 → 135, then again after
the
origin-reasoning correction above. #283found a third driver that survived both, and the ledger moved by zero for
it, before and after:
A composed reading was built, discarded at the registry door, and independently
re-derived — two classifications of one type, by two paths that never met. It was
latent, not active: nothing in the tree compared the two answers, so a
disagreement would have produced wrong output and no signal. The #266 shape again.
The fix is not where the issue first said. #281
proposed moving the composers behind a registry API; that would have closed
nothing, because the loss is at the door, and it happened again at every
recursion step —
immediate_edgeshad each child as a&TypeRefand called.syntax().clone()so the next level could re-classify it. The correction wasposted on the issue before implementing.
ensure_entry,register_type_*,require_*andunrequire_*take aTypeRef;immediate_edgesreturns one;intern/intern_recursiveare the singlefallible door,
pub(in crate::api::core)so an adapter still cannot mint areading by classifying tokens of its own.
Flat::classifyis down to ONEproduction caller.
Two things fell out rather than being argued for. Infallibility:
ensure_entrywas fallible for exactly one reason —
classifyrefusing a spelling — and areading has already been through that, so #281's plan to assert that layering is
total and pin it with a test became unnecessary. And ten of the twelve
require_*sites already held aTypeRefand called.syntax()at the door, socarrying it was a deletion.
regen-checkbyte-identical is evidence here, not a regression check: the cellused to hold
classify(spelling)and now holds the caller's reading, so identicaloutput is the first confirmation that the two answers agree for every type the
examples exercise.
Where
api/coreends, and why it is not zero11 sites:
types_util9,registry/scan2.unfold's last one —peel_ref— went with #278, and onetypes_utilhelper with #279.Every classifying helper still in
types_utilis called overwhelmingly from theadapters —
option_inner_type40 times,bare_path_ident22,is_unit18 — andnone takes the model as an argument, so it cannot consult it from the inside. L2
stopped
api/corefrom calling them; only L3 and L4 can free them to be deleted.The two in
registry/scanare different and stay for good: they inspect a key abuild-script author wrote, to diagnose that spelling — no source type is being
classified, so there is no element to read instead. They are the first entries to
land in the "legitimately the adapter's business" category this document predicts.
L3 —
Cbindgenconsumes elementsbuilder(8),trait_impl(6),emit(5),mod(5),convert(1)Variant::spell, not fromre-deriving delimiters
Variant::syntax, and the number comesfrom
Variant::discriminantL4 —
JniGenconsumes elements — in progressThe lookup surface takes the reading
(#284 →
#285,
#286,
#288): The registration path carries readings instead of re-deriving them #283 made
registration reading-based; this did the same for lookup.
reading(&TypeKey)is the one keyed door,conversion/input_entry/output_entrytake a&TypeRef, andreading_of(&syn::Type)is thevisible "I only had tokens" step. So an entry lookup cannot be called
about a type the registry does not know — Seal
TypeRef: only the model may mint one #280 sealed minting, so aTypeRefcomes only from the model or from those doorsA sum's match pattern keeps its own delimiters
(#304):
encode_sum_groupbuilt each arm by branching on
variant.fields.first(), so an alternativewith no fields took the
Nonearm and was spelled bare —myflat::E::Bfor
enum E { B() }, which is E0533: a zero-field tuple or structvariant still needs its delimiters in pattern position
The
.jobject_input()decoders read the element(#289 →
#302,
#303; the flatten path
went first in #294):
struct_input_bodyandsum_input_bodywalkedsyn::Fieldswhile theircallers already held the
flat::Struct/ were onedeclared_typeawayfrom the
Type::Variant.flat_input.rsis now off the spellingcensus — 18 → 0 — with no
option_inner_type,reading_of,bare_path_ident,pat_match_top,SumSpecorsyn::FieldsleftThe long pole, and the counted 97 is the smaller half of it. Measured before
starting: 34
origin.syntaxreads and ~118 calls totypes_util'sclassifying helpers, neither visible to the ledger.
option_inner_typealone iscalled 40 times from jnigen,
bare_path_ident22,is_unit14. Those are the tenhelpers L2 could not delete, and L4 is what frees them.
The crossing hands over the reading
(#264):
convert_crossingrebuilt a spelling with
key.to_type()and re-classified it, while theregistry held the reading in the cell — jnigen's currency was
TypeKey, sothe whole dispatch ran on spellings.
Conversionsgainsreading, whichboth the partial and total views answer from the same cell, and the selector
takes a
&TypeRef. Fixing the door is what stops a file-by-file migrationfrom producing new instances underneath itself
The layer questions, selector half
(#272): converter
selection decided what a type was by rendering a wildcard pattern from
its spelling and comparing the string (
pat_match(pat, "Option < _ >")×10), then rebuilt the type it generates by name
(
parse_quote!(Option<#t1>)×7).Box<Option<T>>reconstructed asBox<_>, matched nothing, and got no converter at all — whileselect_output_typewas handed only a spelling, becauseconvert_crossingfetched the reading and discarded it. Dispatch is
TypeKindnow and thesignature is
origin.syntax;pat_match,with_first_argandref_wildcardare deleted, andselector.rswent 8 → 3. Transparentwrappers became one table —
core::flatowns the set it erases,the adapter owns what Rust can do with each, and a test fails if they
disagree — so adding
Rcis one entry and one row rather than a huntThe layer questions, Kotlin surface + the struct chain
(#274, Kotlin nullability is decided by
is_option_type, a by-name check #273):is_option_typedecided nullability by name, so a wrappedOptionparameter rendered non-null while the identical-meaning plain one
rendered
String?— a wrong contract, since Kotlin then rejectsnullatthe call site.
Conversionsgained the model accessors, and thestruct/data-class chain went further and carries the element:
classify_fieldtakes&flat::TypeRef,build_struct_plantakes&flat::Struct, the sum walk zipsflat::Variant::alternatives. Askingabout an unregistered type is a compile error there now, and two runtime
guards became unrepresentable.
is_option_refdeletedThe layer questions, remainder:
emit/flat_input(20),emit/wrapper,emit/delivery,fold,trait_impl— the counts arecommitted per file in
jnigen'sspelling_census, which walks tokens andresolves
use … as …(the sibling adapter aliases these helpers today).This is what retires
option_inner_typeandpeel_ref_option_vecCore's plan leaves carry the reading
(#278):
UnfoldLeaf::out_tyandFoldLeaf::tyweresyn::Type, and the readingwas discarded at construction —
flattenpeelsTypeRefs and storedorigin.syntax. Most producers simply stop discarding; five genuinelycompose a type no source wrote (a borrow, two
Optionlayers, apresence flag, a selector), and
Flat::classifyis rightly unavailable tothem, so
flatgainedTypeRef::{borrowed, optional, scalar, named}—each pairing
kindwith its own spelling. Composed-from-nothing isplaceless, the call
classifyalready makes.a_composed_type_keys_as_its_spellingpins the thing that would otherwisebreak silently: a composed
&Tkeying differently would register adifferent cell
Adapter-side reading sources, and the last accessor goes
(#279, closing
#275): the remaining 3
callers were both places the adapter obtains a type rather than consumes
one — callback args reconstructed from
TypeKeys instead of read offTypeKind::Callback { args: Vec<TypeRef> }, and a sum payload taken from a&syn::Field.trait_implstopped.map(|a| a.origin.syntax.clone())-ingaway args it already held;
write_sealed_classeszipsflat::Variant::alternatives.Conversions::is_optionalis deleted —grep -rn "is_optional" prebindgen/srcreturns onlyPathStep::is_optional,which was this issue's acceptance test. Ledger 129 → 127
Names and identity:
emit/names(17),render(8),overloads.TypeIdis the name;
bare_path_identtakes a path apart to re-derive itThe enum shape:
emit/convert(4), plusenum_shape,enum_discriminant_values,first_payload_variant— L2c, which turned outto be entirely L4's
classify.rs— mostly legitimate, and the ledger cannot see it at all.It answers "how is this type declared to me" (
Handle/Enum/Sumfrom
DeclaredKind), which is the adapter's own business. Its one leak wasDataStruct { st: &syn::ItemStruct }, whereflat::Structexists —closed by #267, which
needed it: a field record could not carry its own reading while the walk
was handed a
syn::ItemStruct.walk_value_formnow iteratesflat::Struct.fields, andunfold.rsleft the ledger entirely (136 → 135)reject_unsupported_array_lengthre-checks a grammarflat::array_lenowns— Array-length qualification needs one walk, not a validator and a rewriter that disagree #210's drift one layer down. Delete it, with the subset check as evidence
The reading comes from the declaration
(#267):
Registry::readingfell back to
Flat::classifyon a miss, and the fallback fired onscalars —
i64×48,String×25 — becauseunfold's value-form walkasked about the leaves its caller registers one loop later.
classifyanswered correctly, so an ordering bug produced correct output and no
signal.
FieldRecord::tyis now aTypeRef,readingis a pure lookup,and L4b-1/L4b-2 are unblocked
The emitters stop assuming a Rust representation
(#271, Value-form field: an
Optionbehind a transparent wrapper emits an undereferenced access #268): the dual ofL4a: the crossing hands over the reading #264's defect, and the direction nothing was watching. L4a: the crossing hands over the reading #264 fixed consumers
re-classifying from
origin.syntax; this fixed an emitter classifying offkindcorrectly and then spelling offkindtoo —match place { Some(..) }isE0308the moment the source writesBox<Option<T>>, whichthe model erases by design. Three destructures coerced; a token-walking
census over the whole
emit/directory keeps a fourth from appearingsilently
Generated Rust and Kotlin byte-identical
What stays, and it finally has members.
QualifyEmittedTypeswalks generateditems to qualify paths, and the wire-shape matching in
prim/prim_array/wire_accessinspects JNI types the adapter synthesized. Both are the exemptionthis document predicted, and each PR that touches them records it rather than
leaving it implicit.
#264 is also the clearest case yet that the count is the wrong axis. It removed
the re-parse at the dispatch door — the single most load-bearing round trip in the
adapter — and the ledger went up, 135 → 136, because the spelling guard it
added is two honest
synmatches. A stage that reported only its delta would havescored that as a regression.
What L4 taught: an erasure sits outside the layer it wraps
The model erases
BoxandCow, and that erasure is right —Box<Option<T>>is one optional to every destination. But conversion follows the syntax, and
the two facts a rebuild needs were not on the model.
#293 added them as derived
readings,
TypeRef::erased_wrappers()andstripped_syntax()— derived becausea stored spelling is a second thing that can disagree with
syntax, and #290'serased_wrapper()had already established the shape. Defined by an invariantrather than by a loop: the stripped spelling is the one whose own lowering
yields exactly this
kind, so the peel runs to a fixed point (Box<Box<T>>classifies as
T, and one strip leaves aBox<T>that does not match). The testasserts that property and was seen to fail against a one-layer implementation.
The rule, which outlives the stage:
Box<&Vec<T>>classifies asRef; peel that first and the wrapper is gone fromeverywhere a consumer will look.
&Box<Vec<T>>hides it on the referent, where aquestion asked of the outer
syn::Type::Referencecannot see it. Neither checksubsumes the other and the two classify identically, so a walk must ask at
every layer on the way down — which is also why the wrapper is a list,
gathered as the walk descends rather than read once at the top.
The audit found the population is two, and only one has to ask. A site that
classifies — which Kotlin type, which JNI wire, which C spelling — must never
consult the wrapper; that is the erasure working, and every cbindgen site and all
but one jnigen site are of that kind. A site that binds a source value and
destructures or rebuilds it must. There was exactly one unguarded instance and
it was a live miscompilation: builder delivery bound the returned value and
matched it against
Option's patterns, which match ergonomics does not seethrough a
Box. Fixed at the single point the value enters the delivery, not ateach of the four matches downstream. #290's own comment had flagged the output
side and cbindgen as unchecked; this is that check.
Two findings about evidence, which is what this stage keeps being about.
syn::Typeparameter cleanup #290's guards were a hand-maintained set of peel sites and were wrong twice inone PR. "Are all the peel sites guarded?" is answered by inspection until the
model carries the facts and one shared helper consumes them — which is the
argument for doing the model half first, against the issue's own "not urgent".
contains(..)assertionspass on Rust that does not compile
(#269). The fixture that
proves this one is in
perftest-flat, whose generated binding covertestinclude!s and builds — the only place in the tree where anE0308is atest failure. Verified by disabling the fix and watching the build break, then
round-tripped on the JVM. The ledger, again, did not move: no watched
synvariant site is added or removed.
L5 — close the seam
The public contract stops being
syn, which is what stops the population fromgrowing back.
Registry's public item maps stop being the adapter-facing contract —done early by L1.5, which deleted them outright; relates to
#92
What a spelling adds over its classification is the model's answer
(#293):
erased_wrappers()/stripped_syntax(). Belongs to this stage becausethe alternative was every rebuilding emitter taking a
syn::Typeapart foritself — the population growing back through a door the ledger does not
watch. The completion criterion below already forbids reconstructing a
spelling from a classification; this is the fact that makes obeying it
possible, and it is deliberately derived so the model gains no
representation state to keep in sync
The item methods take elements
(#276):
on_function/on_struct/on_enum/on_constwere the widest part of thepublic
synsurface and the one that decided what adapters could know —an adapter handed a
flat::Functioncannot ask what a parameter means andbe told "no reading".
on_enumsplit intoon_variant+on_enumalongthe model's own distinction; they still sort together, only dispatch
differs. Nine sites stopped
.map(|f| &f.origin.syntax)-ing away anelement they had just fetched. Zero external cost: all six implementors
are in-crate. Landed out of stage order because it was blocking L4's
accessor deletion, not because L5 started
A key is an identity, not a second door to
syn::Type(#291 →
#298,
#299,
#300,
#301): Seal
TypeRef: only the model may mint one #280 sealedminting, and
TypeKey::to_type()walked straight around the seal — itkept the parsed form beside the canonical string and handed it to anyone
holding a key, so a caller could spell a type the model never classified.
TypeKeyis now theRc<str>alone. 44 call sites → 0, and thegenerated output was byte-identical at every one of the four steps
Only the model may mint a reading
(#280):
TypeRefwaspub struct { pub kind, pub origin }with four public composers, so anyconsumer could assemble one and nothing checked that
kindagreed withorigin.syntax. Holding one proved nothing. Fields arepub(super),composers and
Flat::classifyarepub(in crate::api::core), and reads gothrough
kind()/syntax()/location()— a public field IS aconstructor, so restricting only the composers would have blocked
nothing.
Prebindgen::post_process_item(&mut syn::Item)— the hook that letqualification live in an adapter in the first place
ConverterImpl::function/TypeEntry::functionassyn::ItemFn;prerequisites/local_functionsreturning raw itemsNiches { value: syn::Expr, matches: syn::Expr }— a semantic fact carriedas raw expression syntax
Extend the ledger's
WATCHEDbeyondType/Expr—Item,Fields,FnArg,ReturnType,GenericArgument,Pat— one enum at a time, eachaddition a regenerated ledger whose diff is the decision
Close or accept the blind spots the ledger header lists (token-string
classification, ident-name classification, helper delegation)
#280's follow-ups. #281 is
closed by #283 — see "a driver
the ledger cannot see at all" under L2. Two remain:
scan_fn/scan_struct/scan_enumstilltake
synitems and walkfield.ty/pt.ty/sig.outputas raw syntax, whileflat::Field::ty,Param::tyandFunction::retare alreadyTypeRefs — thesame discard The registration path carries readings instead of re-deriving them #283 removed, for source types rather than composed ones. Same rule,
second population, separately reviewable
population, same defect.
emit/flat_input.rswalkssyn::Fields::Namedwhileflat::Struct::fieldsalready carries aTypeRefper field, which is why it holds20 of the 34
option_inner_typecallers. Not a helper swap: the peels are wrong(
option_inner_typereads the last path segment, soBox<Option<T>>answers "notoptional"), and the file is the reason they persist. The first change in this
sequence that should actually move the ledger —
types_utilis 9 of the 11remaining
api/coreentries, stuck since L2 precisely because its callers had nomodel to consult
syn::Typecensus — storing or composingsyn::Typeabove the model.Must come after the two above, which define its population, and it needs a
new counter:
boundary.ledgersees neither asyn::Type-typed field norparse_quote!(Option<#t1>), since neither names a variant.spelling_censusis the precedent to extend
Completion criteria
#211's, restated for this design:
Flat::builder().items(..).build(), whichRegistry::from_itemsalso routesthrough.
CbindgenandJniGentake every source fact from an element.has reached the irreducible set, and every remaining entry is documented as
inspecting adapter-synthesized types.
diagnostics naming item and component.
syntaxslice, never byreconstructing one from a classification.
Relationship to #215
#215 is superseded. Its four merged PRs are not lost: L0 ports the
array-length subgrammar (#212), the type grammar and its acceptance tests, the
enum tag/discriminant numbering (#226) and the boundary ledger (#224). What is
dropped is the syn-free model itself —
SourceType::to_syn,DiscriminantSource,VariantShape,NamedArg::Lifetime— because carrying thesource's own slice does that job without a modelling cost.
The
source-frontendbranch stays in place as the reference. Nothing depends onit, and it is not a base for anything here: every stage of this program lands on
language-integration, which merges tomainwhen the program does.Review protocol
Each stage PR states its own exit:
examples/regen-check.shdid, always. The check isinstrumentation, not a constraint: it says what moved, not whether the
change was allowed. Byte-identical is the strongest evidence a refactor did
nothing unintended and is worth claiming when it holds — but generated output
that moves without changing semantics or performance is fully acceptable, and
no architecture decision may be reshaped to keep bytes matching.
performance neutral. A movement outside that explanation is a bug.
Run the check the way that makes it mean something:
git clean -fd examples/first (an earlier
--all-featuresrun leaves artifacts the check reads as drift),then
cargo clean -p example-cbindgen -p example-flat(it only regenerates whatcargo decides to rebuild, so a cached run passes without checking anything).